Commit 71feb068 by Davorin Sego Committed by Martyn James

Dashboard search feature

parent 75d37591
"""
Dashboard search
"""
from bok_choy.page_object import PageObject
from . import BASE_URL
class DashboardSearchPage(PageObject):
"""
Dashboard page featuring a search form
"""
search_bar_selector = '#dashboard-search-bar'
url = "{base}/dashboard".format(base=BASE_URL)
@property
def search_results(self):
""" search results list showing """
return self.q(css='#dashboard-search-results')
def is_browser_on_page(self):
""" did we find the search bar in the UI """
return self.q(css=self.search_bar_selector).present
def enter_search_term(self, text):
""" enter the search term into the box """
self.q(css=self.search_bar_selector + ' input[type="text"]').fill(text)
def search(self):
""" execute the search """
self.q(css=self.search_bar_selector + ' [type="submit"]').click()
self.wait_for_element_visibility('.search-info', 'Search results are shown')
def search_for_term(self, text):
"""
Search and return results
"""
self.enter_search_term(text)
self.search()
...@@ -33,6 +33,7 @@ class TabNavPage(PageObject): ...@@ -33,6 +33,7 @@ class TabNavPage(PageObject):
else: else:
self.warning("No tabs found for '{0}'".format(tab_name)) self.warning("No tabs found for '{0}'".format(tab_name))
self.wait_for_page()
self._is_on_tab_promise(tab_name).fulfill() self._is_on_tab_promise(tab_name).fulfill()
def is_on_tab(self, tab_name): def is_on_tab(self, tab_name):
......
"""
Test dashboard search
"""
import os
import json
from bok_choy.web_app_test import WebAppTest
from ..helpers import generate_course_key
from ...pages.common.logout import LogoutPage
from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage
from ...pages.lms.dashboard_search import DashboardSearchPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
class DashboardSearchTest(WebAppTest):
"""
Test dashboard search.
"""
USERNAME = 'STUDENT_TESTER'
EMAIL = 'student101@example.com'
STAFF_USERNAME = "STAFF_TESTER"
STAFF_EMAIL = "staff101@example.com"
TEST_INDEX_FILENAME = "test_root/index_file.dat"
def setUp(self):
"""
Create the search page and courses to search.
"""
# create test file in which index for this test will live
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
json.dump({}, index_file)
super(DashboardSearchTest, self).setUp()
self.dashboard = DashboardSearchPage(self.browser)
self.courses = {
'A': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_A',
'display_name': 'Test Course A '
},
'B': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_B',
'display_name': 'Test Course B '
},
'C': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_C',
'display_name': 'Test Course C '
}
}
# generate course fixtures and outline pages
self.course_outlines = {}
self.course_fixtures = {}
for key, course_info in self.courses.iteritems():
course_outline = CourseOutlinePage(
self.browser,
course_info['org'],
course_info['number'],
course_info['run']
)
course_fix = CourseFixture(
course_info['org'],
course_info['number'],
course_info['run'],
course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Section 1').add_children(
XBlockFixtureDesc('sequential', 'Subsection 1').add_children(
XBlockFixtureDesc('problem', 'dashboard search')
)
)
).add_children(
XBlockFixtureDesc('chapter', 'Section 2').add_children(
XBlockFixtureDesc('sequential', 'Subsection 2')
)
).install()
self.course_outlines[key] = course_outline
self.course_fixtures[key] = course_fix
def tearDown(self):
"""
Remove index file
"""
super(DashboardSearchTest, self).tearDown()
os.remove(self.TEST_INDEX_FILENAME)
def _auto_auth(self, username, email, staff):
"""
Logout and login with given credentials.
"""
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, username=username, email=email, staff=staff).visit()
def _studio_add_content(self, course_outline, html_content):
"""
Add content to first section on studio course page.
"""
# create a unit in course outline
course_outline.visit()
subsection = course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
subsection.add_unit()
# got to unit and create an HTML component and save (not publish)
unit_page = ContainerPage(self.browser, None)
unit_page.wait_for_page()
add_html_component(unit_page, 0)
unit_page.wait_for_element_presence('.edit-button', 'Edit button is visible')
click_css(unit_page, '.edit-button', 0, require_notification=False)
unit_page.wait_for_element_visibility('.modal-editor', 'Modal editor is visible')
type_in_codemirror(unit_page, 0, html_content)
click_css(unit_page, '.action-save', 0)
def _studio_publish_content(self, course_outline):
"""
Publish content in first section on studio course page.
"""
course_outline.visit()
subsection = course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
unit = subsection.unit_at(0)
unit.publish()
def test_page_existence(self):
"""
Make sure that the page exists.
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
def test_search(self):
"""
Make sure that you can search courses.
"""
search_string = "dashboard"
html_content = "dashboard search"
# Enroll student in courses A & B, but not C
for course_info in [self.courses['A'], self.courses['B']]:
course_key = generate_course_key(
course_info['org'],
course_info['number'],
course_info['run']
)
AutoAuthPage(
self.browser,
username=self.USERNAME,
email=self.EMAIL,
course_id=course_key
).visit()
# Create content in studio without publishing.
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
self._studio_add_content(self.course_outlines['A'], html_content)
self._studio_add_content(self.course_outlines['B'], html_content)
self._studio_add_content(self.course_outlines['C'], html_content)
# Do a search, there should be no results shown.
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
self.dashboard.search_for_term(search_string)
assert search_string not in self.dashboard.search_results.html[0]
# Publish in studio to trigger indexing.
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
self._studio_publish_content(self.course_outlines['A'])
self._studio_publish_content(self.course_outlines['B'])
self._studio_publish_content(self.course_outlines['C'])
# Do the search again, this time we expect results from courses A & B, but not C
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
self.dashboard.search_for_term(search_string)
assert self.dashboard.search_results.html[0].count(search_string) == 2
assert self.dashboard.search_results.html[0].count(self.courses['A']['display_name']) == 1
assert self.dashboard.search_results.html[0].count(self.courses['B']['display_name']) == 1
...@@ -179,7 +179,7 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT) ...@@ -179,7 +179,7 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT) YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT)
YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT) YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
if FEATURES.get('ENABLE_COURSEWARE_SEARCH'): if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
# Use MockSearchEngine as the search engine for test scenario # Use MockSearchEngine as the search engine for test scenario
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
......
...@@ -565,7 +565,7 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get( ...@@ -565,7 +565,7 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get(
'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM 'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM
) )
if FEATURES.get('ENABLE_COURSEWARE_SEARCH'): if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
# Use ElasticSearch as the search engine herein # Use ElasticSearch as the search engine herein
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
......
...@@ -120,6 +120,10 @@ PASSWORD_COMPLEXITY = {} ...@@ -120,6 +120,10 @@ PASSWORD_COMPLEXITY = {}
# Enable courseware search for tests # Enable courseware search for tests
FEATURES['ENABLE_COURSEWARE_SEARCH'] = True FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
# Enable dashboard search for tests
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
# Use MockSearchEngine as the search engine for test scenario # Use MockSearchEngine as the search engine for test scenario
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# Path at which to store the mock index # Path at which to store the mock index
......
...@@ -357,6 +357,9 @@ FEATURES = { ...@@ -357,6 +357,9 @@ FEATURES = {
# Courseware search feature # Courseware search feature
'ENABLE_COURSEWARE_SEARCH': False, 'ENABLE_COURSEWARE_SEARCH': False,
# Dashboard search feature
'ENABLE_DASHBOARD_SEARCH': False,
# log all information from cybersource callbacks # log all information from cybersource callbacks
'LOG_POSTPAY_CALLBACKS': True, 'LOG_POSTPAY_CALLBACKS': True,
...@@ -1103,7 +1106,7 @@ courseware_js = ( ...@@ -1103,7 +1106,7 @@ courseware_js = (
for pth in ['courseware', 'histogram', 'navigation', 'time'] for pth in ['courseware', 'histogram', 'navigation', 'time']
] + ] +
['js/' + pth + '.js' for pth in ['ajax-error']] + ['js/' + pth + '.js' for pth in ['ajax-error']] +
['js/search/main.js'] + ['js/search/course/main.js'] +
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js')) sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
) )
...@@ -1135,7 +1138,10 @@ main_vendor_js = base_vendor_js + [ ...@@ -1135,7 +1138,10 @@ main_vendor_js = base_vendor_js + [
'js/vendor/URI.min.js', 'js/vendor/URI.min.js',
] ]
dashboard_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/dashboard/**/*.js')) dashboard_js = (
sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/dashboard/**/*.js')) +
['js/search/dashboard/main.js']
)
discussion_js = sorted(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/discussion/**/*.js')) discussion_js = sorted(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
rwd_header_footer_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/common_helpers/rwd_header_footer.js')) rwd_header_footer_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/common_helpers/rwd_header_footer.js'))
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js')) staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
...@@ -2215,6 +2221,8 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12 ...@@ -2215,6 +2221,8 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12
SEARCH_ENGINE = None SEARCH_ENGINE = None
# Use the LMS specific result processor # Use the LMS specific result processor
SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor" SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor"
# Use the LMS specific filter generator
SEARCH_FILTER_GENERATOR = "lms.lib.courseware_search.lms_filter_generator.LmsSearchFilterGenerator"
### PERFORMANCE EXPERIMENT SETTINGS ### ### PERFORMANCE EXPERIMENT SETTINGS ###
# CDN experiment/monitoring flags # CDN experiment/monitoring flags
......
...@@ -121,6 +121,10 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True ...@@ -121,6 +121,10 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
########################## Dashboard Search #######################
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
########################## Certificates Web/HTML View ####################### ########################## Certificates Web/HTML View #######################
FEATURES['CERTIFICATES_HTML_VIEW'] = True FEATURES['CERTIFICATES_HTML_VIEW'] = True
......
...@@ -461,6 +461,10 @@ FEATURES['ENTRANCE_EXAMS'] = True ...@@ -461,6 +461,10 @@ FEATURES['ENTRANCE_EXAMS'] = True
# Enable courseware search for tests # Enable courseware search for tests
FEATURES['ENABLE_COURSEWARE_SEARCH'] = True FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
# Enable dashboard search for tests
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
# Use MockSearchEngine as the search engine for test scenario # Use MockSearchEngine as the search engine for test scenario
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
......
"""
This file contains implementation override of SearchFilterGenerator which will allow
* Filter by all courses in which the user is enrolled in
"""
from student.models import CourseEnrollment
from search.filter_generator import SearchFilterGenerator
class LmsSearchFilterGenerator(SearchFilterGenerator):
""" SearchFilterGenerator for LMS Search """
def field_dictionary(self, **kwargs):
""" add course if provided otherwise add courses in which the user is enrolled in """
field_dictionary = super(LmsSearchFilterGenerator, self).field_dictionary(**kwargs)
if not kwargs.get('user'):
field_dictionary['course'] = []
elif not kwargs.get('course_id'):
user_enrollments = CourseEnrollment.enrollments_for_user(kwargs['user'])
field_dictionary['course'] = [unicode(enrollment.course_id) for enrollment in user_enrollments]
return field_dictionary
...@@ -20,6 +20,7 @@ class LmsSearchResultProcessor(SearchResultProcessor): ...@@ -20,6 +20,7 @@ class LmsSearchResultProcessor(SearchResultProcessor):
""" SearchResultProcessor for LMS Search """ """ SearchResultProcessor for LMS Search """
_course_key = None _course_key = None
_course_name = None
_usage_key = None _usage_key = None
_module_store = None _module_store = None
_module_temp_dictionary = {} _module_temp_dictionary = {}
...@@ -62,6 +63,16 @@ class LmsSearchResultProcessor(SearchResultProcessor): ...@@ -62,6 +63,16 @@ class LmsSearchResultProcessor(SearchResultProcessor):
) )
@property @property
def course_name(self):
"""
Display the course name when searching multiple courses - retain result for subsequent uses
"""
if self._course_name is None:
course = self.get_module_store().get_course(self.get_course_key())
self._course_name = course.display_name_with_default
return self._course_name
@property
def location(self): def location(self):
""" """
Blend "location" property into the resultset, so that the path to the found component can be shown within the UI Blend "location" property into the resultset, so that the path to the found component can be shown within the UI
......
"""
Tests for the lms_filter_generator
"""
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator
class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase):
""" Test case class to test search result processor """
def build_courses(self):
"""
Build up a course tree with multiple test courses
"""
self.courses = [
CourseFactory.create(
org='ElasticsearchFiltering',
course='ES101F',
run='test_run',
display_name='Elasticsearch Filtering test course',
),
CourseFactory.create(
org='FilterTest',
course='FT101',
run='test_run',
display_name='FilterTest test course',
)
]
def setUp(self):
super(LmsSearchFilterGeneratorTestCase, self).setUp()
self.build_courses()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
for course in self.courses:
CourseEnrollment.enroll(self.user, course.location.course_key)
def test_course_id_not_provided(self):
"""
Tests that we get the list of IDs of courses the user is enrolled in when the course ID is null or not provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user)
self.assertTrue('start_date' in filter_dictionary)
self.assertIn(unicode(self.courses[0].id), field_dictionary['course'])
self.assertIn(unicode(self.courses[1].id), field_dictionary['course'])
def test_course_id_provided(self):
"""
Tests that we get the course ID when the course ID is provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters(
user=self.user,
course_id=unicode(self.courses[0].id)
)
self.assertTrue('start_date' in filter_dictionary)
self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
def test_user_not_provided(self):
"""
Tests that we get empty list of courses in case the user is not provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters()
self.assertTrue('start_date' in filter_dictionary)
self.assertEqual(0, len(field_dictionary['course']))
...@@ -85,6 +85,17 @@ class LmsSearchResultProcessorTestCase(ModuleStoreTestCase): ...@@ -85,6 +85,17 @@ class LmsSearchResultProcessorTestCase(ModuleStoreTestCase):
self.assertEqual( self.assertEqual(
srp.url, "/courses/{}/jump_to/{}".format(unicode(self.course.id), unicode(self.html.scope_ids.usage_id))) srp.url, "/courses/{}/jump_to/{}".format(unicode(self.course.id), unicode(self.html.scope_ids.usage_id)))
def test_course_name_parameter(self):
srp = LmsSearchResultProcessor(
{
"course": unicode(self.course.id),
"id": unicode(self.html.scope_ids.usage_id),
"content": {"text": "This is the html text"}
},
"test"
)
self.assertEqual(srp.course_name, self.course.display_name)
def test_location_parameter(self): def test_location_parameter(self):
srp = LmsSearchResultProcessor( srp = LmsSearchResultProcessor(
{ {
......
<div id="courseware-search-bar" class="search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">Course Search</label>
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
search <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</form>
</div>
<div id="dashboard-search-bar" class="search-bar" role="search" aria-label="Dashboard">
<form>
<label for="dashboard-search-input">Search Your Courses</label>
<div>
<input id="dashboard-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">
<i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
<div id="courseware-search-bar" class="search-container">
<form role="search-form">
<input type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">search <i class="icon-search"></i></button>
<button type="button" class="cancel-button" aria-label="Cancel"><i class="icon-remove"></i></button>
</form>
</div>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
define([ define([
'backbone', 'backbone',
'js/search/models/search_result' 'js/search/base/models/search_result'
], function (Backbone, SearchResult) { ], function (Backbone, SearchResult) {
'use strict'; 'use strict';
...@@ -11,6 +11,7 @@ define([ ...@@ -11,6 +11,7 @@ define([
model: SearchResult, model: SearchResult,
pageSize: 20, pageSize: 20,
totalCount: 0, totalCount: 0,
latestModelsCount: 0,
accessDeniedCount: 0, accessDeniedCount: 0,
searchTerm: '', searchTerm: '',
page: 0, page: 0,
...@@ -20,17 +21,15 @@ define([ ...@@ -20,17 +21,15 @@ define([
initialize: function (models, options) { initialize: function (models, options) {
// call super constructor // call super constructor
Backbone.Collection.prototype.initialize.apply(this, arguments); Backbone.Collection.prototype.initialize.apply(this, arguments);
if (options && options.course_id) { if (options && options.courseId) {
this.url += options.course_id; this.url += options.courseId;
} }
}, },
performSearch: function (searchTerm) { performSearch: function (searchTerm) {
this.fetchXhr && this.fetchXhr.abort(); this.fetchXhr && this.fetchXhr.abort();
this.searchTerm = searchTerm || ''; this.searchTerm = searchTerm || '';
this.totalCount = 0; this.resetState();
this.accessDeniedCount = 0;
this.page = 0;
this.fetchXhr = this.fetch({ this.fetchXhr = this.fetch({
data: { data: {
search_string: searchTerm, search_string: searchTerm,
...@@ -71,20 +70,34 @@ define([ ...@@ -71,20 +70,34 @@ define([
cancelSearch: function () { cancelSearch: function () {
this.fetchXhr && this.fetchXhr.abort(); this.fetchXhr && this.fetchXhr.abort();
this.page = 0; this.resetState();
this.totalCount = 0;
this.accessDeniedCount = 0;
}, },
parse: function(response) { parse: function(response) {
this.latestModelsCount = response.results.length;
this.totalCount = response.total; this.totalCount = response.total;
this.accessDeniedCount += response.access_denied_count; this.accessDeniedCount += response.access_denied_count;
this.totalCount -= this.accessDeniedCount; this.totalCount -= this.accessDeniedCount;
return _.map(response.results, function(result){ return result.data; }); return _.map(response.results, function (result) {
return result.data;
});
},
resetState: function () {
this.page = 0;
this.totalCount = 0;
this.latestModelsCount = 0;
this.accessDeniedCount = 0;
// empty the entire collection
this.reset();
}, },
hasNextPage: function () { hasNextPage: function () {
return this.totalCount - ((this.page + 1) * this.pageSize) > 0; return this.totalCount - ((this.page + 1) * this.pageSize) > 0;
},
latestModels: function () {
return this.last(this.latestModelsCount);
} }
}); });
......
...@@ -4,9 +4,12 @@ define(['backbone'], function (Backbone) { ...@@ -4,9 +4,12 @@ define(['backbone'], function (Backbone) {
'use strict'; 'use strict';
return Backbone.Router.extend({ return Backbone.Router.extend({
routes: { routes: {
'search/:query': 'search' 'search/:query': 'search'
} },
search: function(query) {
this.trigger('search', query);
}
}); });
}); });
......
...@@ -5,7 +5,7 @@ define(['jquery', 'backbone'], function ($, Backbone) { ...@@ -5,7 +5,7 @@ define(['jquery', 'backbone'], function ($, Backbone) {
return Backbone.View.extend({ return Backbone.View.extend({
el: '#courseware-search-bar', el: '',
events: { events: {
'submit form': 'submitForm', 'submit form': 'submitForm',
'click .cancel-button': 'clearSearch', 'click .cancel-button': 'clearSearch',
...@@ -40,9 +40,13 @@ define(['jquery', 'backbone'], function ($, Backbone) { ...@@ -40,9 +40,13 @@ define(['jquery', 'backbone'], function ($, Backbone) {
} }
}, },
clearSearch: function () { resetSearchForm: function () {
this.$searchField.val(''); this.$searchField.val('');
this.setInitialStyle(); this.setInitialStyle();
},
clearSearch: function () {
this.resetSearchForm();
this.trigger('clear'); this.trigger('clear');
}, },
......
...@@ -7,11 +7,12 @@ define([ ...@@ -7,11 +7,12 @@ define([
'gettext', 'gettext',
'logger' 'logger'
], function ($, _, Backbone, gettext, Logger) { ], function ($, _, Backbone, gettext, Logger) {
'use strict'; 'use strict';
return Backbone.View.extend({ return Backbone.View.extend({
tagName: 'li', tagName: 'li',
templateId: '',
className: 'search-results-item', className: 'search-results-item',
attributes: { attributes: {
'role': 'region', 'role': 'region',
...@@ -19,16 +20,22 @@ define([ ...@@ -19,16 +20,22 @@ define([
}, },
events: { events: {
'click .search-results-item a': 'logSearchItem', 'click': 'logSearchItem',
}, },
initialize: function () { initialize: function () {
var template_name = (this.model.attributes.content_type === "Sequence") ? '#search_item_seq-tpl' : '#search_item-tpl'; this.tpl = _.template($(this.templateId).html());
this.tpl = _.template($(template_name).html());
}, },
render: function () { render: function () {
this.$el.html(this.tpl(this.model.attributes)); var data = _.clone(this.model.attributes);
// Drop the preview text and result type if the search term is found
// in the title/location in the course hierarchy
if (this.model.get('content_type') === 'Sequence') {
data.excerpt = '';
data.content_type = '';
}
this.$el.html(this.tpl(data));
return this; return this;
}, },
...@@ -44,24 +51,23 @@ define([ ...@@ -44,24 +51,23 @@ define([
event.preventDefault(); event.preventDefault();
var self = this; var self = this;
var target = this.model.id; var target = this.model.id;
var link = $(event.target).attr('href'); var link = this.model.get('url');
var collection = this.model.collection; var collection = this.model.collection;
var page = collection.page; var page = collection.page;
var pageSize = collection.pageSize; var pageSize = collection.pageSize;
var searchTerm = collection.searchTerm; var searchTerm = collection.searchTerm;
var index = collection.indexOf(this.model); var index = collection.indexOf(this.model);
Logger.log("edx.course.search.result_selected", Logger.log('edx.course.search.result_selected', {
{ 'search_term': searchTerm,
"search_term": searchTerm, 'result_position': (page * pageSize + index),
"result_position": (page * pageSize + index), 'result_link': target
"result_link": target }).always(function() {
}).always(function() { self.redirect(link);
self.redirect(link); });
});
} }
}); });
}); });
})(define || RequireJS.define); })(define || RequireJS.define);
...@@ -5,39 +5,39 @@ define([ ...@@ -5,39 +5,39 @@ define([
'underscore', 'underscore',
'backbone', 'backbone',
'gettext', 'gettext',
'js/search/views/search_item_view' ], function ($, _, Backbone, gettext) {
], function ($, _, Backbone, gettext, SearchItemView) {
'use strict'; 'use strict';
return Backbone.View.extend({ return Backbone.View.extend({
el: '#courseware-search-results', // these should be defined by subclasses
events: { el: '',
'click .search-load-next': 'loadNext' contentElement: '',
}, resultsTemplateId: '',
spinner: '.icon', loadingTemplateId: '',
errorTemplateId: '',
events: {},
spinner: '.search-load-next .icon',
SearchItemView: function () {},
initialize: function () { initialize: function () {
this.courseName = this.$el.attr('data-course-name'); this.$contentElement = $(this.contentElement);
this.$courseContent = $('#course-content'); this.resultsTemplate = _.template($(this.resultsTemplateId).html());
this.listTemplate = _.template($('#search_list-tpl').html()); this.loadingTemplate = _.template($(this.loadingTemplateId).html());
this.loadingTemplate = _.template($('#search_loading-tpl').html()); this.errorTemplate = _.template($(this.errorTemplateId).html());
this.errorTemplate = _.template($('#search_error-tpl').html());
this.collection.on('search', this.render, this);
this.collection.on('next', this.renderNext, this);
this.collection.on('error', this.showErrorMessage, this);
}, },
render: function () { render: function () {
this.$el.html(this.listTemplate({ this.$el.html(this.resultsTemplate({
totalCount: this.collection.totalCount, totalCount: this.collection.totalCount,
totalCountMsg: this.totalCountMsg(), totalCountMsg: this.totalCountMsg(),
pageSize: this.collection.pageSize, pageSize: this.collection.pageSize,
hasMoreResults: this.collection.hasNextPage() hasMoreResults: this.collection.hasNextPage()
})); }));
this.renderItems(); this.renderItems();
this.$courseContent.hide(); this.$el.find(this.spinner).hide();
this.$contentElement.hide();
this.$el.show(); this.$el.show();
return this; return this;
}, },
...@@ -53,11 +53,12 @@ define([ ...@@ -53,11 +53,12 @@ define([
}, },
renderItems: function () { renderItems: function () {
var items = this.collection.map(function (result) { var latest = this.collection.latestModels();
var item = new SearchItemView({ model: result }); var items = latest.map(function (result) {
var item = new this.SearchItemView({ model: result });
return item.render().el; return item.render().el;
}); }, this);
this.$el.find('.search-results').html(items); this.$el.find('ol').append(items);
}, },
totalCountMsg: function () { totalCountMsg: function () {
...@@ -67,25 +68,26 @@ define([ ...@@ -67,25 +68,26 @@ define([
clear: function () { clear: function () {
this.$el.hide().empty(); this.$el.hide().empty();
this.$courseContent.show(); this.$contentElement.show();
}, },
showLoadingMessage: function () { showLoadingMessage: function () {
this.$el.html(this.loadingTemplate()); this.$el.html(this.loadingTemplate());
this.$el.show(); this.$el.show();
this.$courseContent.hide(); this.$contentElement.hide();
}, },
showErrorMessage: function () { showErrorMessage: function () {
this.$el.html(this.errorTemplate()); this.$el.html(this.errorTemplate());
this.$el.show(); this.$el.show();
this.$courseContent.hide(); this.$contentElement.hide();
}, },
loadNext: function (event) { loadNext: function (event) {
event && event.preventDefault(); event && event.preventDefault();
this.$el.find(this.spinner).show(); this.$el.find(this.spinner).show();
this.trigger('next'); this.trigger('next');
return false;
} }
}); });
......
RequireJS.require([
'jquery',
'backbone',
'js/search/course/search_app',
'js/search/base/routers/search_router',
'js/search/course/views/search_form',
'js/search/base/collections/search_collection',
'js/search/course/views/search_results_view'
], function ($, Backbone, SearchApp, SearchRouter, CourseSearchForm, SearchCollection, CourseSearchResultsView) {
'use strict';
var courseId = $('#courseware-search-results').data('courseId');
var app = new SearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
Backbone.history.start();
});
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (courseId, SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([], { courseId: courseId });
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#courseware-search-bar'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#course_search_item-tpl'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/course/views/search_item_view'
], function (SearchResultsView, CourseSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#courseware-search-results',
contentElement: '#course-content',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
},
SearchItemView: CourseSearchItemView
});
});
})(define || RequireJS.define);
RequireJS.require([
'backbone',
'js/search/dashboard/search_app',
'js/search/base/routers/search_router',
'js/search/dashboard/views/search_form',
'js/search/base/collections/search_collection',
'js/search/dashboard/views/search_results_view'
], function (Backbone, SearchApp, SearchRouter, DashSearchForm, SearchCollection, DashSearchResultsView) {
'use strict';
var app = new SearchApp(
SearchRouter,
DashSearchForm,
SearchCollection,
DashSearchResultsView
);
Backbone.history.start();
});
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([]);
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(results, 'reset', function () {
form.resetSearchForm();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#dashboard-search-bar'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#dashboard_search_item-tpl'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/dashboard/views/search_item_view'
], function (SearchResultsView, DashSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#dashboard-search-results',
contentElement: '#my-courses, #profile-sidebar',
resultsTemplateId: '#dashboard_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
'click .search-back-to-courses': 'backToCourses'
},
SearchItemView: DashSearchItemView,
backToCourses: function () {
this.clear();
this.trigger('reset');
return false;
}
});
});
})(define || RequireJS.define);
RequireJS.require([
'jquery',
'backbone',
'js/search/search_app'
], function ($, Backbone, SearchApp) {
'use strict';
var course_id = $('#courseware-search-results').attr('data-course-id');
var app = new SearchApp(course_id);
Backbone.history.start();
});
;(function (define) {
define([
'backbone',
'js/search/search_router',
'js/search/views/search_form',
'js/search/views/search_list_view',
'js/search/collections/search_collection'
], function(Backbone, SearchRouter, SearchForm, SearchListView, SearchCollection) {
'use strict';
return function (course_id) {
var self = this;
this.router = new SearchRouter();
this.form = new SearchForm();
this.collection = new SearchCollection([], { course_id: course_id });
this.results = new SearchListView({ collection: this.collection });
this.form.on('search', this.results.showLoadingMessage, this.results);
this.form.on('search', this.collection.performSearch, this.collection);
this.form.on('search', function (term) {
self.router.navigate('search/' + term, { replace: true });
});
this.form.on('clear', this.collection.cancelSearch, this.collection);
this.form.on('clear', this.results.clear, this.results);
this.form.on('clear', this.router.navigate, this.router);
this.results.on('next', this.collection.loadNextPage, this.collection);
this.router.on('route:search', this.form.doSearch, this.form);
};
});
})(define || RequireJS.define);
...@@ -4,136 +4,37 @@ define([ ...@@ -4,136 +4,37 @@ define([
'backbone', 'backbone',
'logger', 'logger',
'js/common_helpers/template_helpers', 'js/common_helpers/template_helpers',
'js/search/views/search_form', 'js/search/base/models/search_result',
'js/search/views/search_item_view', 'js/search/base/collections/search_collection',
'js/search/views/search_list_view', 'js/search/base/routers/search_router',
'js/search/models/search_result', 'js/search/course/views/search_item_view',
'js/search/collections/search_collection', 'js/search/dashboard/views/search_item_view',
'js/search/search_router', 'js/search/course/views/search_form',
'js/search/search_app' 'js/search/dashboard/views/search_form',
'js/search/course/views/search_results_view',
'js/search/dashboard/views/search_results_view',
'js/search/course/search_app',
'js/search/dashboard/search_app'
], function( ], function(
$, $,
Sinon, Sinon,
Backbone, Backbone,
Logger, Logger,
TemplateHelpers, TemplateHelpers,
SearchForm,
SearchItemView,
SearchListView,
SearchResult, SearchResult,
SearchCollection, SearchCollection,
SearchRouter, SearchRouter,
SearchApp CourseSearchItemView,
DashSearchItemView,
CourseSearchForm,
DashSearchForm,
CourseSearchResultsView,
DashSearchResultsView,
CourseSearchApp,
DashSearchApp
) { ) {
'use strict'; 'use strict';
describe('SearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search_form.html');
this.form = new SearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', function () {
var term = ' search string ';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
});
it('handles calls to doSearch', function () {
var term = ' search string ';
$('.search-field').val(term);
this.form.doSearch(term);
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
expect($('.search-field').val()).toEqual(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
});
it('triggers a search event and changes to active state', function () {
var term = 'search string';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
});
it('clears search when clicking on cancel button', function () {
$('.search-field').val('search string');
$('.cancel-button').trigger('click');
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.search-button')).toBeVisible();
expect($('.cancel-button')).toBeHidden();
expect($('.search-field')).toHaveValue('');
});
it('clears search when search box is empty', function() {
$('.search-field').val('');
$('form').trigger('submit');
expect(this.onClear).toHaveBeenCalled();
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.cancel-button')).toBeHidden();
expect($('.search-button')).toBeVisible();
});
});
describe('SearchItemView', function () {
beforeEach(function () {
TemplateHelpers.installTemplate('templates/courseware_search/search_item');
TemplateHelpers.installTemplate('templates/courseware_search/search_item_seq');
this.model = {
attributes: {
location: ['section', 'subsection', 'unit'],
content_type: 'Video',
excerpt: 'A short excerpt.',
url: 'path/to/content'
}
};
this.item = new SearchItemView({ model: this.model });
});
it('has useful html attributes', function () {
expect(this.item.$el).toHaveAttr('role', 'region');
expect(this.item.$el).toHaveAttr('aria-label', 'search result');
});
it('renders correctly', function () {
var href = this.model.attributes.url;
var breadcrumbs = 'section ▸ subsection ▸ unit';
this.item.render();
expect(this.item.$el).toContainHtml(this.model.attributes.content_type);
expect(this.item.$el).toContainHtml(this.model.attributes.excerpt);
expect(this.item.$el).toContain('a[href="'+href+'"]');
expect(this.item.$el).toContainHtml(breadcrumbs);
});
it('log request on follow item link', function () {
this.model.collection = new SearchCollection([this.model], { course_id: 'edx101' });
this.item.render();
// Mock the redirect call
spyOn(this.item, 'redirect').andCallFake( function() {} );
spyOn(this.item, 'logSearchItem').andCallThrough();
spyOn(Logger, 'log').andReturn($.Deferred().resolve());
var link = this.item.$el.find('a');
expect(link.length).toBe(1);
link.trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
});
});
describe('SearchResult', function () { describe('SearchResult', function () {
...@@ -171,9 +72,16 @@ define([ ...@@ -171,9 +72,16 @@ define([
this.server.restore(); this.server.restore();
}); });
it('appends course_id to url', function () { it('sends a request without a course ID', function () {
var collection = new SearchCollection([], { course_id: 'edx101' }); var collection = new SearchCollection([]);
expect(collection.url).toEqual('/search/edx101'); collection.performSearch('search string');
expect(this.server.requests[0].url).toEqual('/search/');
});
it('sends a request with course ID', function () {
var collection = new SearchCollection([], { courseId: 'edx101' });
collection.performSearch('search string');
expect(this.server.requests[0].url).toEqual('/search/edx101');
}); });
it('sends a request and parses the json result', function () { it('sends a request and parses the json result', function () {
...@@ -195,6 +103,7 @@ define([ ...@@ -195,6 +103,7 @@ define([
expect(this.onSearch).toHaveBeenCalled(); expect(this.onSearch).toHaveBeenCalled();
expect(this.collection.totalCount).toEqual(1); expect(this.collection.totalCount).toEqual(1);
expect(this.collection.latestModelsCount).toEqual(1);
expect(this.collection.accessDeniedCount).toEqual(1); expect(this.collection.accessDeniedCount).toEqual(1);
expect(this.collection.page).toEqual(0); expect(this.collection.page).toEqual(0);
expect(this.collection.first().attributes).toEqual(response.results[0].data); expect(this.collection.first().attributes).toEqual(response.results[0].data);
...@@ -216,8 +125,9 @@ define([ ...@@ -216,8 +125,9 @@ define([
}); });
it('sends correct paging parameters', function () { it('sends correct paging parameters', function () {
this.collection.performSearch('search string'); var searchString = 'search string';
var response = { total: 52, results: [] }; var response = { total: 52, results: [] };
this.collection.performSearch(searchString);
this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]); this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
this.server.respond(); this.server.respond();
this.collection.loadNextPage(); this.collection.loadNextPage();
...@@ -225,11 +135,9 @@ define([ ...@@ -225,11 +135,9 @@ define([
spyOn($, 'ajax'); spyOn($, 'ajax');
this.collection.loadNextPage(); this.collection.loadNextPage();
expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url); expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url);
expect($.ajax.mostRecentCall.args[0].data).toEqual({ expect($.ajax.mostRecentCall.args[0].data.search_string).toEqual(searchString);
search_string : 'search string', expect($.ajax.mostRecentCall.args[0].data.page_size).toEqual(this.collection.pageSize);
page_size : this.collection.pageSize, expect($.ajax.mostRecentCall.args[0].data.page_index).toEqual(2);
page_index : 2
});
}); });
it('has next page', function () { it('has next page', function () {
...@@ -266,18 +174,23 @@ define([ ...@@ -266,18 +174,23 @@ define([
beforeEach(function () { beforeEach(function () {
this.collection.page = 2; this.collection.page = 2;
this.collection.totalCount = 35; this.collection.totalCount = 35;
this.collection.latestModelsCount = 5;
}); });
it('resets state when performing new search', function () { it('resets state when performing new search', function () {
this.collection.performSearch('search string'); this.collection.performSearch('search string');
expect(this.collection.models.length).toEqual(0);
expect(this.collection.page).toEqual(0); expect(this.collection.page).toEqual(0);
expect(this.collection.totalCount).toEqual(0); expect(this.collection.totalCount).toEqual(0);
expect(this.collection.latestModelsCount).toEqual(0);
}); });
it('resets state when canceling a search', function () { it('resets state when canceling a search', function () {
this.collection.cancelSearch(); this.collection.cancelSearch();
expect(this.collection.models.length).toEqual(0);
expect(this.collection.page).toEqual(0); expect(this.collection.page).toEqual(0);
expect(this.collection.totalCount).toEqual(0); expect(this.collection.totalCount).toEqual(0);
expect(this.collection.latestModelsCount).toEqual(0);
}); });
}); });
...@@ -285,145 +198,350 @@ define([ ...@@ -285,145 +198,350 @@ define([
}); });
describe('SearchListView', function () { describe('SearchRouter', function () {
beforeEach(function () { beforeEach(function () {
setFixtures( this.router = new SearchRouter();
'<section id="courseware-search-results" data-course-name="Test Course"></section>' + this.onSearch = jasmine.createSpy('onSearch');
'<section id="course-content"></section>' this.router.on('search', this.onSearch);
); });
it ('has a search route', function () {
expect(this.router.routes['search/:query']).toEqual('search');
});
it ('triggers a search event', function () {
var query = 'mercury';
this.router.search(query);
expect(this.onSearch).toHaveBeenCalledWith(query);
});
});
describe('SearchItemView', function () {
function beforeEachHelper(SearchItemView) {
TemplateHelpers.installTemplates([ TemplateHelpers.installTemplates([
'templates/courseware_search/search_item', 'templates/search/course_search_item',
'templates/courseware_search/search_item_seq', 'templates/search/dashboard_search_item'
'templates/courseware_search/search_list',
'templates/courseware_search/search_loading',
'templates/courseware_search/search_error'
]); ]);
var MockCollection = Backbone.Collection.extend({ this.model = new SearchResult({
hasNextPage: function (){} location: ['section', 'subsection', 'unit'],
content_type: 'Video',
course_name: 'Course Name',
excerpt: 'A short excerpt.',
url: 'path/to/content'
}); });
this.collection = new MockCollection();
// spy on these methods before they are bound to events this.seqModel = new SearchResult({
spyOn(SearchListView.prototype, 'render').andCallThrough(); location: ['section', 'subsection'],
spyOn(SearchListView.prototype, 'renderNext').andCallThrough(); content_type: 'Sequence',
spyOn(SearchListView.prototype, 'showErrorMessage').andCallThrough(); course_name: 'Course Name',
excerpt: 'A short excerpt.',
url: 'path/to/content'
});
this.item = new SearchItemView({ model: this.model });
this.item.render();
this.seqItem = new SearchItemView({ model: this.seqModel });
this.seqItem.render();
}
function rendersItem() {
expect(this.item.$el).toHaveAttr('role', 'region');
expect(this.item.$el).toHaveAttr('aria-label', 'search result');
expect(this.item.$el).toContain('a[href="' + this.model.get('url') + '"]');
expect(this.item.$el.find('.result-type')).toContainHtml(this.model.get('content_type'));
expect(this.item.$el.find('.result-excerpt')).toContainHtml(this.model.get('excerpt'));
expect(this.item.$el.find('.result-location')).toContainHtml('section ▸ subsection ▸ unit');
}
function rendersSequentialItem() {
expect(this.seqItem.$el).toHaveAttr('role', 'region');
expect(this.seqItem.$el).toHaveAttr('aria-label', 'search result');
expect(this.seqItem.$el).toContain('a[href="' + this.seqModel.get('url') + '"]');
expect(this.seqItem.$el.find('.result-type')).toBeEmpty();
expect(this.seqItem.$el.find('.result-excerpt')).toBeEmpty();
expect(this.seqItem.$el.find('.result-location')).toContainHtml('section ▸ subsection');
}
function logsSearchItemViewEvent() {
this.model.collection = new SearchCollection([this.model], { course_id: 'edx101' });
this.item.render();
// Mock the redirect call
spyOn(this.item, 'redirect').andCallFake( function() {} );
spyOn(Logger, 'log').andReturn($.Deferred().resolve());
this.item.$el.find('a').trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
this.item.$el.trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
}
this.listView = new SearchListView({ collection: this.collection }); describe('CourseSearchItemView', function () {
beforeEach(function () {
beforeEachHelper.call(this, CourseSearchItemView);
});
it('renders items correctly', rendersItem);
it('renders Sequence items correctly', rendersSequentialItem);
it('logs view event', logsSearchItemViewEvent);
}); });
it('shows loading message', function () { describe('DashSearchItemView', function () {
this.listView.showLoadingMessage(); beforeEach(function () {
expect($('#course-content')).toBeHidden(); beforeEachHelper.call(this, DashSearchItemView);
expect(this.listView.$el).toBeVisible(); });
expect(this.listView.$el).not.toBeEmpty(); it('renders items correctly', rendersItem);
it('renders Sequence items correctly', rendersSequentialItem);
it('displays course name in breadcrumbs', function () {
expect(this.seqItem.$el.find('.result-course-name')).toContainHtml(this.model.get('course_name'));
});
it('logs view event', logsSearchItemViewEvent);
}); });
it('shows error message', function () { });
this.listView.showErrorMessage();
expect($('#course-content')).toBeHidden();
expect(this.listView.$el).toBeVisible(); describe('SearchForm', function () {
expect(this.listView.$el).not.toBeEmpty();
function trimsInputString() {
var term = ' search string ';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
}
function doesSearch() {
var term = ' search string ';
$('.search-field').val(term);
this.form.doSearch(term);
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
expect($('.search-field').val()).toEqual(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
}
function triggersSearchEvent() {
var term = 'search string';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
}
function clearsSearchOnCancel() {
$('.search-field').val('search string');
$('.search-button').trigger('click');
$('.cancel-button').trigger('click');
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.search-button')).toBeVisible();
expect($('.cancel-button')).toBeHidden();
expect($('.search-field')).toHaveValue('');
}
function clearsSearchOnEmpty() {
$('.search-field').val('');
$('form').trigger('submit');
expect(this.onClear).toHaveBeenCalled();
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.cancel-button')).toBeHidden();
expect($('.search-button')).toBeVisible();
}
describe('CourseSearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
this.form = new CourseSearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', trimsInputString);
it('handles calls to doSearch', doesSearch);
it('triggers a search event and changes to active state', triggersSearchEvent);
it('clears search when clicking on cancel button', clearsSearchOnCancel);
it('clears search when search box is empty', clearsSearchOnEmpty);
}); });
it('returns to content', function () { describe('DashSearchForm', function () {
this.listView.clear(); beforeEach(function () {
expect($('#course-content')).toBeVisible(); loadFixtures('js/fixtures/search/dashboard_search_form.html');
expect(this.listView.$el).toBeHidden(); this.form = new DashSearchForm();
expect(this.listView.$el).toBeEmpty(); this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', trimsInputString);
it('handles calls to doSearch', doesSearch);
it('triggers a search event and changes to active state', triggersSearchEvent);
it('clears search when clicking on cancel button', clearsSearchOnCancel);
it('clears search when search box is empty', clearsSearchOnEmpty);
}); });
it('handles events', function () { });
this.collection.trigger('search');
this.collection.trigger('next');
this.collection.trigger('error');
expect(this.listView.render).toHaveBeenCalled();
expect(this.listView.renderNext).toHaveBeenCalled();
expect(this.listView.showErrorMessage).toHaveBeenCalled();
});
it('renders a message when there are no results', function () { describe('SearchResultsView', function () {
function showsLoadingMessage () {
this.resultsView.showLoadingMessage();
expect(this.resultsView.$contentElement).toBeHidden();
expect(this.resultsView.$el).toBeVisible();
expect(this.resultsView.$el).not.toBeEmpty();
}
function showsErrorMessage () {
this.resultsView.showErrorMessage();
expect(this.resultsView.$contentElement).toBeHidden();
expect(this.resultsView.$el).toBeVisible();
expect(this.resultsView.$el).not.toBeEmpty();
}
function returnsToContent () {
this.resultsView.clear();
expect(this.resultsView.$contentElement).toBeVisible();
expect(this.resultsView.$el).toBeHidden();
expect(this.resultsView.$el).toBeEmpty();
}
function showsNoResultsMessage() {
this.collection.reset(); this.collection.reset();
this.listView.render(); this.resultsView.render();
expect(this.listView.$el).toContainHtml('no results'); expect(this.resultsView.$el).toContainHtml('no results');
expect(this.listView.$el.find('ol')).not.toExist(); expect(this.resultsView.$el.find('ol')).not.toExist();
}); }
it('renders search results', function () { function rendersSearchResults () {
var searchResults = [{ var searchResults = [{
location: ['section', 'subsection', 'unit'], location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content', url: '/some/url/to/content',
content_type: 'text', content_type: 'text',
course_name: '',
excerpt: 'this is a short excerpt' excerpt: 'this is a short excerpt'
}]; }];
this.collection.set(searchResults); this.collection.set(searchResults);
this.collection.latestModelsCount = 1;
this.collection.totalCount = 1; this.collection.totalCount = 1;
this.listView.render(); this.resultsView.render();
expect(this.listView.$el.find('ol')[0]).toExist(); expect(this.resultsView.$el.find('ol')[0]).toExist();
expect(this.listView.$el.find('li').length).toEqual(1); expect(this.resultsView.$el.find('li').length).toEqual(1);
expect(this.listView.$el).toContainHtml('Search Results'); expect(this.resultsView.$el).toContainHtml('Search Results');
expect(this.listView.$el).toContainHtml('this is a short excerpt'); expect(this.resultsView.$el).toContainHtml('this is a short excerpt');
searchResults[1] = searchResults[0]
this.collection.set(searchResults); this.collection.set(searchResults);
this.collection.totalCount = 2; this.collection.totalCount = 2;
this.listView.renderNext(); this.resultsView.renderNext();
expect(this.listView.$el.find('.search-count')).toContainHtml('2'); expect(this.resultsView.$el.find('.search-count')).toContainHtml('2');
expect(this.listView.$el.find('li').length).toEqual(2); expect(this.resultsView.$el.find('li').length).toEqual(2);
}); }
it('shows a link to load more results', function () { function showsMoreResultsLink () {
this.collection.totalCount = 123; this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; }; this.collection.hasNextPage = function () { return true; };
this.listView.render(); this.resultsView.render();
expect(this.listView.$el.find('a.search-load-next')[0]).toExist(); expect(this.resultsView.$el.find('a.search-load-next')[0]).toExist();
this.collection.totalCount = 123; this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return false; }; this.collection.hasNextPage = function () { return false; };
this.listView.render(); this.resultsView.render();
expect(this.listView.$el.find('a.search-load-next')[0]).not.toExist(); expect(this.resultsView.$el.find('a.search-load-next')[0]).not.toExist();
}); }
it('triggers an event for next page', function () { function triggersNextPageEvent () {
var onNext = jasmine.createSpy('onNext'); var onNext = jasmine.createSpy('onNext');
this.listView.on('next', onNext); this.resultsView.on('next', onNext);
this.collection.totalCount = 123; this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; }; this.collection.hasNextPage = function () { return true; };
this.listView.render(); this.resultsView.render();
this.listView.$el.find('a.search-load-next').click(); this.resultsView.$el.find('a.search-load-next').click();
expect(onNext).toHaveBeenCalled(); expect(onNext).toHaveBeenCalled();
}); }
it('shows a spinner when loading more results', function () { function showsLoadMoreSpinner () {
this.collection.totalCount = 123; this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; }; this.collection.hasNextPage = function () { return true; };
this.listView.render(); this.resultsView.render();
this.listView.loadNext(); expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
this.resultsView.loadNext();
// Do we really need to check if a loading indicator exists? - CR // toBeVisible does not work with inline
expect(this.resultsView.$el.find('a.search-load-next .icon')).toHaveCss({ 'display': 'inline' });
// jasmine.Clock.useMock(1000); this.resultsView.renderNext();
// expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeVisible(); expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
this.listView.renderNext(); }
// jasmine.Clock.useMock(1000);
// expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeHidden(); function beforeEachHelper(SearchResultsView) {
}); appendSetFixtures(
'<section id="courseware-search-results"></section>' +
}); '<section id="course-content"></section>' +
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
describe('SearchRouter', function () { TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
'templates/search/course_search_results',
'templates/search/dashboard_search_results',
'templates/search/search_list',
'templates/search/search_loading',
'templates/search/search_error'
]);
beforeEach(function () { var MockCollection = Backbone.Collection.extend({
this.router = new SearchRouter(); hasNextPage: function () {},
}); latestModelsCount: 0,
pageSize: 20,
latestModels: function () {
return SearchCollection.prototype.latestModels.apply(this, arguments);
}
});
this.collection = new MockCollection();
this.resultsView = new SearchResultsView({ collection: this.collection });
}
it ('has a search route', function () { describe('CourseSearchResultsView', function () {
expect(this.router.routes['search/:query']).toEqual('search'); beforeEach(function() {
beforeEachHelper.call(this, CourseSearchResultsView);
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
it('returns to content', returnsToContent);
it('shows a message when there are no results', showsNoResultsMessage);
it('renders search results', rendersSearchResults);
it('shows a link to load more results', showsMoreResultsLink);
it('triggers an event for next page', triggersNextPageEvent);
it('shows a spinner when loading more results', showsLoadMoreSpinner);
});
describe('DashSearchResultsView', function () {
beforeEach(function() {
beforeEachHelper.call(this, DashSearchResultsView);
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
it('returns to content', returnsToContent);
it('shows a message when there are no results', showsNoResultsMessage);
it('renders search results', rendersSearchResults);
it('shows a link to load more results', showsMoreResultsLink);
it('triggers an event for next page', triggersNextPageEvent);
it('shows a spinner when loading more results', showsLoadMoreSpinner);
it('returns back to courses', function () {
var onReset = jasmine.createSpy('onReset');
this.resultsView.on('reset', onReset);
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-back-to-courses')).toExist();
this.resultsView.$el.find('.search-back-to-courses').click();
expect(onReset).toHaveBeenCalled();
expect(this.resultsView.$contentElement).toBeVisible();
expect(this.resultsView.$el).toBeHidden();
});
}); });
}); });
...@@ -431,21 +549,17 @@ define([ ...@@ -431,21 +549,17 @@ define([
describe('SearchApp', function () { describe('SearchApp', function () {
beforeEach(function () { function showsLoadingMessage () {
loadFixtures('js/fixtures/search_form.html'); $('.search-field').val('search string');
appendSetFixtures( $('.search-button').trigger('click');
'<section id="courseware-search-results" data-course-name="Test Course"></section>' + expect(this.$contentElement).toBeHidden();
'<section id="course-content"></section>' expect(this.$searchResults).toBeVisible();
); expect(this.$searchResults).not.toBeEmpty();
TemplateHelpers.installTemplates([ }
'templates/courseware_search/search_item',
'templates/courseware_search/search_item_seq',
'templates/courseware_search/search_list',
'templates/courseware_search/search_loading',
'templates/courseware_search/search_error'
]);
this.server = Sinon.fakeServer.create(); function performsSearch () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({ this.server.respondWith([200, {}, JSON.stringify({
total: 1337, total: 1337,
access_denied_count: 12, access_denied_count: 12,
...@@ -454,82 +568,203 @@ define([ ...@@ -454,82 +568,203 @@ define([
location: ['section', 'subsection', 'unit'], location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content', url: '/some/url/to/content',
content_type: 'text', content_type: 'text',
excerpt: 'this is a short excerpt' excerpt: 'this is a short excerpt',
course_name: ''
} }
}] }]
})]); })]);
this.server.respond();
expect($('.search-info')).toExist();
expect($('.search-result-list')).toBeVisible();
expect(this.$searchResults.find('li').length).toEqual(1);
}
Backbone.history.stop(); function showsErrorMessage () {
this.app = new SearchApp('a/b/c');
// start history after the application has finished creating
// all of its routers
Backbone.history.start();
});
afterEach(function () {
this.server.restore();
});
it ('shows loading message on search', function () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
expect($('#course-content')).toBeHidden();
expect($('#courseware-search-results')).toBeVisible();
expect($('#courseware-search-results')).not.toBeEmpty();
});
it ('performs search', function () {
$('.search-field').val('search string'); $('.search-field').val('search string');
$('.search-button').trigger('click'); $('.search-button').trigger('click');
this.server.respondWith([500, {}]);
this.server.respond(); this.server.respond();
expect($('.search-info')).toExist(); expect(this.$searchResults).toEqual($('#search_error-tpl'));
expect($('.search-results')).toBeVisible(); }
});
it ('updates navigation history on search', function () { function updatesNavigationHistory () {
$('.search-field').val('edx'); $('.search-field').val('edx');
$('.search-button').trigger('click'); $('.search-button').trigger('click');
expect(Backbone.history.fragment).toEqual('search/edx'); expect(Backbone.history.navigate.calls[0].args).toContain('search/edx');
}); $('.cancel-button').trigger('click');
expect(Backbone.history.navigate.calls[1].args).toContain('');
}
it ('aborts sent search request', function () { function cancelsSearchRequest () {
// send search request to server // send search request to server
$('.search-field').val('search string'); $('.search-field').val('search string');
$('.search-button').trigger('click'); $('.search-button').trigger('click');
// cancel search // cancel search
$('.cancel-button').trigger('click'); $('.cancel-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond(); this.server.respond();
// there should be no results // there should be no results
expect($('#course-content')).toBeVisible(); expect(this.$contentElement).toBeVisible();
expect($('#courseware-search-results')).toBeHidden(); expect(this.$searchResults).toBeHidden();
}); }
it ('clears results', function () { function clearsResults () {
$('.cancel-button').trigger('click'); $('.cancel-button').trigger('click');
expect($('#course-content')).toBeVisible(); expect(this.$contentElement).toBeVisible();
expect($('#courseware-search-results')).toBeHidden(); expect(this.$searchResults).toBeHidden();
}); }
it ('updates navigation history on clear', function () {
$('.cancel-button').trigger('click');
expect(Backbone.history.fragment).toEqual('');
});
it ('loads next page', function () { function loadsNextPage () {
$('.search-field').val('query'); $('.search-field').val('query');
$('.search-button').trigger('click'); $('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond(); this.server.respond();
expect(this.$searchResults.find('li').length).toEqual(1);
expect($('.search-load-next')).toBeVisible(); expect($('.search-load-next')).toBeVisible();
$('.search-load-next').trigger('click'); $('.search-load-next').trigger('click');
var body = this.server.requests[1].requestBody; var body = this.server.requests[1].requestBody;
expect(body).toContain('search_string=query'); expect(body).toContain('search_string=query');
expect(body).toContain('page_index=1'); expect(body).toContain('page_index=1');
}); this.server.respond();
expect(this.$searchResults.find('li').length).toEqual(2);
}
it ('navigates to search', function () { function navigatesToSearch () {
Backbone.history.loadUrl('search/query'); Backbone.history.loadUrl('search/query');
expect(this.server.requests[0].requestBody).toContain('search_string=query'); expect(this.server.requests[0].requestBody).toContain('search_string=query');
}
function loadTemplates () {
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
'templates/search/search_loading',
'templates/search/search_error',
'templates/search/course_search_results',
'templates/search/dashboard_search_results'
]);
}
describe('CourseSearchApp', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<section id="course-content"></section>'
);
loadTemplates.call(this);
this.server = Sinon.fakeServer.create();
var courseId = 'a/b/c';
this.app = new CourseSearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#course-content');
this.$searchResults = $('#courseware-search-results');
});
afterEach(function () {
this.server.restore();
});
it('shows loading message on search', showsLoadingMessage);
it('performs search', performsSearch);
it('updates navigation history', updatesNavigationHistory);
it('cancels search request', cancelsSearchRequest);
it('clears results', clearsResults);
it('loads next page', loadsNextPage);
it('navigates to search', navigatesToSearch);
});
describe('DashSearchApp', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/dashboard_search_form.html');
appendSetFixtures(
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
loadTemplates.call(this);
this.server = Sinon.fakeServer.create();
this.app = new DashSearchApp(
SearchRouter,
DashSearchForm,
SearchCollection,
DashSearchResultsView
);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#my-courses');
this.$searchResults = $('#dashboard-search-results');
});
afterEach(function () {
this.server.restore();
});
it('shows loading message on search', showsLoadingMessage);
it('performs search', performsSearch);
it('updates navigation history', updatesNavigationHistory);
it('cancels search request', cancelsSearchRequest);
it('clears results', clearsResults);
it('loads next page', loadsNextPage);
it('navigates to search', navigatesToSearch);
it('returns to course list', function () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond();
expect($('.search-back-to-courses')).toExist();
$('.search-back-to-courses').trigger('click');
expect(this.$contentElement).toBeVisible();
expect(this.$searchResults).toBeHidden();
expect(this.$searchResults).toBeEmpty();
});
}); });
}); });
......
...@@ -89,7 +89,8 @@ fixture_paths: ...@@ -89,7 +89,8 @@ fixture_paths:
- templates/verify_student - templates/verify_student
- templates/file-upload.underscore - templates/file-upload.underscore
- js/fixtures/edxnotes - js/fixtures/edxnotes
- templates/courseware_search - js/fixtures/search
- templates/search
requirejs: requirejs:
paths: paths:
......
...@@ -57,6 +57,11 @@ ...@@ -57,6 +57,11 @@
@import 'multicourse/edge'; @import 'multicourse/edge';
@import 'multicourse/survey-page'; @import 'multicourse/survey-page';
## Import styles for search
% if env["FEATURES"].get("ENABLE_DASHBOARD_SEARCH", False):
@import 'search/_search';
% endif
@import 'developer'; // used for any developer-created scss that needs further polish/refactoring @import 'developer'; // used for any developer-created scss that needs further polish/refactoring
@import 'shame'; // used for any bad-form/orphaned scss @import 'shame'; // used for any bad-form/orphaned scss
## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution ## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution
...@@ -41,9 +41,9 @@ ...@@ -41,9 +41,9 @@
@import 'course/courseware/sidebar'; @import 'course/courseware/sidebar';
@import 'course/courseware/amplifier'; @import 'course/courseware/amplifier';
## Import styles for courseware search ## Import styles for search
% if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"): % if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"):
@import 'course/courseware/courseware_search'; @import 'search/_search';
% endif % endif
// course - modules // course - modules
......
.course-index .courseware-search-bar { .search-bar {
@include box-sizing(border-box); @include box-sizing(border-box);
position: relative; position: relative;
padding: 5px; padding: ($baseline/4);
box-shadow: 0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset;
font-family: $sans-serif; .search-field-wrapper {
position: relative;
}
.search-field { .search-field {
@extend %t-weight2;
@include box-sizing(border-box); @include box-sizing(border-box);
top: 5px; top: 5px;
width: 100%; width: 100%;
@include border-radius(4px); border-radius: 4px;
background: $white-t1; background: $white-t1;
&.is-active { &.is-active {
background: $white; background: $white;
} }
} }
.search-button, .cancel-button { .search-button, .cancel-button,
.search-button:hover, .cancel-button:hover {
@extend %t-icon6;
@extend %t-regular;
@include box-sizing(border-box); @include box-sizing(border-box);
color: #888; @include right(12px);
font-size: 14px;
font-weight: normal;
display: block; display: block;
position: absolute; position: absolute;
right: 12px; top: 0;
top: 5px;
height: 35px;
line-height: 35px;
padding: 0;
border: none; border: none;
box-shadow: none;
background: transparent; background: transparent;
padding: 0;
height: 35px;
color: $gray-l1;
box-shadow: none;
line-height: 35px;
text-shadow: none;
text-transform: none;
} }
.cancel-button { .cancel-button {
...@@ -40,49 +46,60 @@ ...@@ -40,49 +46,60 @@
} }
.search-results {
.courseware-search-results {
display: none; display: none;
padding: 40px;
.search-info { .search-info {
padding-bottom: lh(.75); border-bottom: 4px solid $border-color-l4;
margin-bottom: lh(.50); padding-bottom: $baseline;
border-bottom: 1px solid $gray-l2;
.search-count { .search-count {
float: right; @include float(right);
color: $gray; color: $gray-l1;
} }
} }
.search-results { .search-result-list {
margin: 0;
padding: 0; padding: 0;
} }
.search-results-item { .search-results-item {
@include padding-right(140px);
position: relative; position: relative;
border-bottom: 1px solid $gray-l4; border-bottom: 1px solid $gray-l4;
padding: $baseline ($baseline/2);
list-style-type: none; list-style-type: none;
margin-bottom: lh(.75); cursor: pointer;
padding-bottom: lh(.75);
padding-right: 140px;
.sri-excerpt { &:hover {
color: $gray; background: $gray-l6;
margin-bottom: lh(1);
} }
.sri-type {
.result-excerpt {
margin-bottom: $baseline;
}
.result-type {
@include right($baseline/2);
position: absolute; position: absolute;
right: 0; bottom: $baseline;
top: 0; font-size: 14px;
color: $gray; color: $gray-l1;
}
.result-course-name {
@include margin-right(1em);
font-size: 14px;
color: $gray-l1;
} }
.sri-link { .result-location {
font-size: 14px;
color: $gray-l1;
}
.result-link {
@include right($baseline/2);
position: absolute; position: absolute;
right: 0; top: $baseline;
line-height: 1.6em; line-height: 1.6em;
bottom: lh(.75);
text-transform: uppercase; text-transform: uppercase;
} }
.search-results-ellipsis { .search-results-ellipsis {
...@@ -97,14 +114,64 @@ ...@@ -97,14 +114,64 @@
.search-load-next { .search-load-next {
display: block; display: block;
text-transform: uppercase;
color: $base-font-color;
border: 2px solid $link-color; border: 2px solid $link-color;
@include border-radius(3px);
padding: 1rem; padding: 1rem;
.icon-spin { border-radius: 3px;
display: none; color: $base-font-color;
text-transform: uppercase;
}
}
.courseware-search-bar {
box-shadow: 0 1px 0 $white inset, 0 -1px 0 $shadow-l1 inset;
}
.dashboard-search-bar {
@include float(right);
@include margin-left(flex-gutter());
margin-bottom: $baseline;
padding: 0;
width: flex-grid(3);
label {
@extend %t-regular;
font-family: $sans-serif;
color: $gray;
font-size: 13px;
font-style: normal;
text-transform: uppercase;
}
.search-field {
background: $white;
box-shadow: 0 1px 0 0 $white, inset 0 0 3px 0 $shadow-l2;
font-family: $sans-serif;
font-style: normal;
}
}
.dashboard-search-results {
@include float(left);
margin: 0;
padding: 0;
width: flex-grid(9);
min-height: 300px;
.search-info {
padding-bottom: lh(1.75);
a {
display: block;
margin-bottom: lh(.5);
font-size: 13px;
}
h2 {
@extend %t-title5;
@include float(left);
@include clear(left);
} }
} }
}
.courseware-search-results {
padding: ($baseline*2);
} }
...@@ -24,9 +24,9 @@ ${page_title_breadcrumbs(course_name())} ...@@ -24,9 +24,9 @@ ${page_title_breadcrumbs(course_name())}
</script> </script>
% endfor % endfor
% for template_name in ["search_item", "search_item_seq", "search_list", "search_loading", "search_error"]: % for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="courseware_search/${template_name}.underscore" /> <%static:include path="search/${template_name}.underscore" />
</script> </script>
% endfor % endfor
...@@ -149,16 +149,18 @@ ${fragment.foot_html()} ...@@ -149,16 +149,18 @@ ${fragment.foot_html()}
</header> </header>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'): % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="courseware-search-bar" role="search" aria-label="Course"> <div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form> <form>
<label for="course-search-input" class="sr">${_('Course Search')}</label> <label for="course-search-input" class="sr">${_('Course Search')}</label>
<input id="course-search-input" type="text" class="search-field"/> <div class="search-field-wrapper">
<button type="submit" class="search-button"> <input id="course-search-input" type="text" class="search-field"/>
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i> <button type="submit" class="search-button">
</button> ${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}"> </button>
<i class="icon fa fa-remove" aria-hidden="true"></i> <button type="button" class="cancel-button" aria-label="${_('Clear search')}">
</button> <i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form> </form>
</div> </div>
% endif % endif
...@@ -198,7 +200,7 @@ ${fragment.foot_html()} ...@@ -198,7 +200,7 @@ ${fragment.foot_html()}
${fragment.body_html()} ${fragment.body_html()}
</section> </section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'): % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<section class="courseware-search-results" id="courseware-search-results" data-course-id="${course.id}" data-course-name="${course.display_name_with_default}"> <section id="courseware-search-results" class="search-results courseware-search-results" data-course-id="${course.id}">
</section> </section>
% endif % endif
</div> </div>
......
<div class='sri-excerpt'><%= excerpt %></div>
<span class='sri-type'><%- content_type %></span>
<span class='sri-location'><%- location.join(' ▸ ') %></span>
<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<div class='sri-excerpt'></div>
<span class='sri-type'></span>
<span class='sri-location'><%- location.join(' ▸ ') %></span>
<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
...@@ -27,6 +27,12 @@ ...@@ -27,6 +27,12 @@
<%static:include path="dashboard/${template_name}.underscore" /> <%static:include path="dashboard/${template_name}.underscore" />
</script> </script>
% endfor % endfor
% for template_name in ["dashboard_search_item", "dashboard_search_results", "search_loading", "search_error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="search/${template_name}.underscore" />
</script>
% endfor
</%block> </%block>
<%block name="js_extra"> <%block name="js_extra">
...@@ -69,8 +75,8 @@ ...@@ -69,8 +75,8 @@
<header class="wrapper-header-courses"> <header class="wrapper-header-courses">
<h2 class="header-courses">${_("Current Courses")}</h2> <h2 class="header-courses">${_("Current Courses")}</h2>
</header> </header>
% if len(course_enrollment_pairs) > 0: % if len(course_enrollment_pairs) > 0:
<ul class="listing-courses"> <ul class="listing-courses">
<% share_settings = settings.FEATURES.get('DASHBOARD_SHARE_SETTINGS', {}) %> <% share_settings = settings.FEATURES.get('DASHBOARD_SHARE_SETTINGS', {}) %>
...@@ -124,7 +130,32 @@ ...@@ -124,7 +130,32 @@
</div> </div>
% endif % endif
</section> </section>
<section class="profile-sidebar" role="region" aria-label="User info">
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<div id="dashboard-search-bar" class="search-bar dashboard-search-bar" role="search" aria-label="Dashboard">
<form>
<label for="dashboard-search-input">${_('Search Your Courses')}</label>
<div class="search-field-wrapper">
<input id="dashboard-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="${_('Search')}">
<i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
% endif
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<section id="dashboard-search-results" class="search-results dashboard-search-results"></section>
% endif
<section class="profile-sidebar" id="profile-sidebar" role="region" aria-label="User info">
<header class="profile">
<h2 class="username-header"><span class="sr">${_("Username")}: </span><span class="username-label">${ user.username }</span></h2>
</header>
<section class="user-info"> <section class="user-info">
<ul> <ul>
<li class="heads-up"> <li class="heads-up">
......
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<span class="result-location"><%- location.join(' ▸ ') %></span>
<span class="result-type"><%- content_type %></span>
...@@ -5,10 +5,10 @@ ...@@ -5,10 +5,10 @@
<% if (totalCount > 0 ) { %> <% if (totalCount > 0 ) { %>
<ol class='search-results'></ol> <ol class='search-result-list'></ol>
<% if (hasMoreResults) { %> <% if (hasMoreResults) { %>
<a class="search-load-next" href="javascript:void(0);"> <a class="search-load-next" href="#">
<%= interpolate( <%= interpolate(
ngettext("Load next %(num_items)s result", "Load next %(num_items)s results", pageSize), ngettext("Load next %(num_items)s result", "Load next %(num_items)s results", pageSize),
{ num_items: pageSize }, { num_items: pageSize },
......
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<span class="result-course-name"><%- course_name %>:</span>
<span class="result-location"><%- location.join(' ▸ ') %></span>
<span class="result-type"><%- content_type %></span>
<header class="search-info">
<a class="search-back-to-courses" href="#"><%= gettext("Back to Dashboard") %></a>
<h2><%= gettext("Search Results") %></h2>
<div class="search-count"><%= totalCountMsg %></div>
</header>
<% if (totalCount > 0 ) { %>
<ol class='search-result-list'></ol>
<% if (hasMoreResults) { %>
<a class="search-load-next" href="#">
<%= interpolate(
ngettext("Load next %(num_items)s result", "Load next %(num_items)s results", pageSize),
{ num_items: pageSize },
true
) %>
<i class="icon fa fa-spinner fa-spin"></i>
</a>
<% } %>
<% } else { %>
<p><%= gettext("Sorry, no results were found.") %></p>
<% } %>
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