diff --git a/common/test/acceptance/pages/lms/dashboard_search.py b/common/test/acceptance/pages/lms/dashboard_search.py new file mode 100644 index 0000000000..ff8cfaaa66 --- /dev/null +++ b/common/test/acceptance/pages/lms/dashboard_search.py @@ -0,0 +1,40 @@ +""" +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() diff --git a/common/test/acceptance/pages/lms/tab_nav.py b/common/test/acceptance/pages/lms/tab_nav.py index 861ee5dd8b..410caff2b3 100644 --- a/common/test/acceptance/pages/lms/tab_nav.py +++ b/common/test/acceptance/pages/lms/tab_nav.py @@ -33,6 +33,7 @@ class TabNavPage(PageObject): else: self.warning("No tabs found for '{0}'".format(tab_name)) + self.wait_for_page() self._is_on_tab_promise(tab_name).fulfill() def is_on_tab(self, tab_name): diff --git a/common/test/acceptance/tests/lms/test_lms_dashboard_search.py b/common/test/acceptance/tests/lms/test_lms_dashboard_search.py new file mode 100644 index 0000000000..23daf5d9e5 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_dashboard_search.py @@ -0,0 +1,193 @@ +""" +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 diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 7826ea2138..0f7918f1a8 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -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['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 SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 9d21ce3760..bff557b189 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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 ) -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 SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 0070b1513f..3d389ca505 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -120,6 +120,10 @@ PASSWORD_COMPLEXITY = {} # Enable courseware search for tests FEATURES['ENABLE_COURSEWARE_SEARCH'] = True + +# Enable dashboard search for tests +FEATURES['ENABLE_DASHBOARD_SEARCH'] = True + # Use MockSearchEngine as the search engine for test scenario SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" # Path at which to store the mock index diff --git a/lms/envs/common.py b/lms/envs/common.py index 9cf4766782..d349d691e9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -357,6 +357,9 @@ FEATURES = { # Courseware search feature 'ENABLE_COURSEWARE_SEARCH': False, + # Dashboard search feature + 'ENABLE_DASHBOARD_SEARCH': False, + # log all information from cybersource callbacks 'LOG_POSTPAY_CALLBACKS': True, @@ -1103,7 +1106,7 @@ courseware_js = ( for pth in ['courseware', 'histogram', 'navigation', 'time'] ] + ['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')) ) @@ -1135,7 +1138,10 @@ main_vendor_js = base_vendor_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')) 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')) @@ -2215,6 +2221,8 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12 SEARCH_ENGINE = None # Use the LMS specific result processor 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 ### # CDN experiment/monitoring flags diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 10cff1c70d..8d229d05dd 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -121,6 +121,10 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" +########################## Dashboard Search ####################### +FEATURES['ENABLE_DASHBOARD_SEARCH'] = True + + ########################## Certificates Web/HTML View ####################### FEATURES['CERTIFICATES_HTML_VIEW'] = True diff --git a/lms/envs/test.py b/lms/envs/test.py index ab098bad30..ff0946b288 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -461,6 +461,10 @@ FEATURES['ENTRANCE_EXAMS'] = True # Enable courseware search for tests FEATURES['ENABLE_COURSEWARE_SEARCH'] = True + +# Enable dashboard search for tests +FEATURES['ENABLE_DASHBOARD_SEARCH'] = True + # Use MockSearchEngine as the search engine for test scenario SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py new file mode 100644 index 0000000000..adfbaf6ca2 --- /dev/null +++ b/lms/lib/courseware_search/lms_filter_generator.py @@ -0,0 +1,22 @@ +""" +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 diff --git a/lms/lib/courseware_search/lms_result_processor.py b/lms/lib/courseware_search/lms_result_processor.py index 0b8d7c2e88..c19be41b24 100644 --- a/lms/lib/courseware_search/lms_result_processor.py +++ b/lms/lib/courseware_search/lms_result_processor.py @@ -20,6 +20,7 @@ class LmsSearchResultProcessor(SearchResultProcessor): """ SearchResultProcessor for LMS Search """ _course_key = None + _course_name = None _usage_key = None _module_store = None _module_temp_dictionary = {} @@ -61,6 +62,16 @@ class LmsSearchResultProcessor(SearchResultProcessor): kwargs={"course_id": self._results_fields["course"], "location": self._results_fields["id"]} ) + @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): """ diff --git a/lms/lib/courseware_search/test/__init__.py b/lms/lib/courseware_search/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py new file mode 100644 index 0000000000..d3f4bcfed5 --- /dev/null +++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py @@ -0,0 +1,72 @@ +""" +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'])) diff --git a/lms/lib/courseware_search/test/test_lms_result_processor.py b/lms/lib/courseware_search/test/test_lms_result_processor.py index fee8d582b9..c07352cbeb 100644 --- a/lms/lib/courseware_search/test/test_lms_result_processor.py +++ b/lms/lib/courseware_search/test/test_lms_result_processor.py @@ -85,6 +85,17 @@ class LmsSearchResultProcessorTestCase(ModuleStoreTestCase): self.assertEqual( 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): srp = LmsSearchResultProcessor( { diff --git a/lms/static/js/fixtures/search/course_search_form.html b/lms/static/js/fixtures/search/course_search_form.html new file mode 100644 index 0000000000..f9f53acf00 --- /dev/null +++ b/lms/static/js/fixtures/search/course_search_form.html @@ -0,0 +1,12 @@ + diff --git a/lms/static/js/fixtures/search/dashboard_search_form.html b/lms/static/js/fixtures/search/dashboard_search_form.html new file mode 100644 index 0000000000..18973767cf --- /dev/null +++ b/lms/static/js/fixtures/search/dashboard_search_form.html @@ -0,0 +1,14 @@ + diff --git a/lms/static/js/fixtures/search_form.html b/lms/static/js/fixtures/search_form.html deleted file mode 100644 index 77c9a1dad8..0000000000 --- a/lms/static/js/fixtures/search_form.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/lms/static/js/search/collections/search_collection.js b/lms/static/js/search/base/collections/search_collection.js similarity index 78% rename from lms/static/js/search/collections/search_collection.js rename to lms/static/js/search/base/collections/search_collection.js index 282be95087..2d32cabd27 100644 --- a/lms/static/js/search/collections/search_collection.js +++ b/lms/static/js/search/base/collections/search_collection.js @@ -2,7 +2,7 @@ define([ 'backbone', - 'js/search/models/search_result' + 'js/search/base/models/search_result' ], function (Backbone, SearchResult) { 'use strict'; @@ -11,6 +11,7 @@ define([ model: SearchResult, pageSize: 20, totalCount: 0, + latestModelsCount: 0, accessDeniedCount: 0, searchTerm: '', page: 0, @@ -20,17 +21,15 @@ define([ initialize: function (models, options) { // call super constructor Backbone.Collection.prototype.initialize.apply(this, arguments); - if (options && options.course_id) { - this.url += options.course_id; + if (options && options.courseId) { + this.url += options.courseId; } }, performSearch: function (searchTerm) { this.fetchXhr && this.fetchXhr.abort(); this.searchTerm = searchTerm || ''; - this.totalCount = 0; - this.accessDeniedCount = 0; - this.page = 0; + this.resetState(); this.fetchXhr = this.fetch({ data: { search_string: searchTerm, @@ -71,20 +70,34 @@ define([ cancelSearch: function () { this.fetchXhr && this.fetchXhr.abort(); - this.page = 0; - this.totalCount = 0; - this.accessDeniedCount = 0; + this.resetState(); }, parse: function(response) { + this.latestModelsCount = response.results.length; this.totalCount = response.total; this.accessDeniedCount += response.access_denied_count; 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 () { return this.totalCount - ((this.page + 1) * this.pageSize) > 0; + }, + + latestModels: function () { + return this.last(this.latestModelsCount); } }); diff --git a/lms/static/js/search/models/search_result.js b/lms/static/js/search/base/models/search_result.js similarity index 100% rename from lms/static/js/search/models/search_result.js rename to lms/static/js/search/base/models/search_result.js diff --git a/lms/static/js/search/search_router.js b/lms/static/js/search/base/routers/search_router.js similarity index 52% rename from lms/static/js/search/search_router.js rename to lms/static/js/search/base/routers/search_router.js index 65683b3f1e..fc4a7adf21 100644 --- a/lms/static/js/search/search_router.js +++ b/lms/static/js/search/base/routers/search_router.js @@ -4,9 +4,12 @@ define(['backbone'], function (Backbone) { 'use strict'; return Backbone.Router.extend({ - routes: { - 'search/:query': 'search' - } + routes: { + 'search/:query': 'search' + }, + search: function(query) { + this.trigger('search', query); + } }); }); diff --git a/lms/static/js/search/views/search_form.js b/lms/static/js/search/base/views/search_form.js similarity index 94% rename from lms/static/js/search/views/search_form.js rename to lms/static/js/search/base/views/search_form.js index 3eb407ac6a..5b666cf6af 100644 --- a/lms/static/js/search/views/search_form.js +++ b/lms/static/js/search/base/views/search_form.js @@ -5,7 +5,7 @@ define(['jquery', 'backbone'], function ($, Backbone) { return Backbone.View.extend({ - el: '#courseware-search-bar', + el: '', events: { 'submit form': 'submitForm', 'click .cancel-button': 'clearSearch', @@ -40,9 +40,13 @@ define(['jquery', 'backbone'], function ($, Backbone) { } }, - clearSearch: function () { + resetSearchForm: function () { this.$searchField.val(''); this.setInitialStyle(); + }, + + clearSearch: function () { + this.resetSearchForm(); this.trigger('clear'); }, diff --git a/lms/static/js/search/views/search_item_view.js b/lms/static/js/search/base/views/search_item_view.js similarity index 57% rename from lms/static/js/search/views/search_item_view.js rename to lms/static/js/search/base/views/search_item_view.js index 30a5565385..57ddaeb226 100644 --- a/lms/static/js/search/views/search_item_view.js +++ b/lms/static/js/search/base/views/search_item_view.js @@ -7,11 +7,12 @@ define([ 'gettext', 'logger' ], function ($, _, Backbone, gettext, Logger) { - 'use strict'; + 'use strict'; return Backbone.View.extend({ tagName: 'li', + templateId: '', className: 'search-results-item', attributes: { 'role': 'region', @@ -19,16 +20,22 @@ define([ }, events: { - 'click .search-results-item a': 'logSearchItem', + 'click': 'logSearchItem', }, initialize: function () { - var template_name = (this.model.attributes.content_type === "Sequence") ? '#search_item_seq-tpl' : '#search_item-tpl'; - this.tpl = _.template($(template_name).html()); + this.tpl = _.template($(this.templateId).html()); }, 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; }, @@ -44,24 +51,23 @@ define([ event.preventDefault(); var self = this; var target = this.model.id; - var link = $(event.target).attr('href'); + var link = this.model.get('url'); var collection = this.model.collection; var page = collection.page; var pageSize = collection.pageSize; var searchTerm = collection.searchTerm; var index = collection.indexOf(this.model); - Logger.log("edx.course.search.result_selected", - { - "search_term": searchTerm, - "result_position": (page * pageSize + index), - "result_link": target - }).always(function() { - self.redirect(link); - }); + Logger.log('edx.course.search.result_selected', { + 'search_term': searchTerm, + 'result_position': (page * pageSize + index), + 'result_link': target + }).always(function() { + self.redirect(link); + }); } + }); }); })(define || RequireJS.define); - diff --git a/lms/static/js/search/views/search_list_view.js b/lms/static/js/search/base/views/search_results_view.js similarity index 59% rename from lms/static/js/search/views/search_list_view.js rename to lms/static/js/search/base/views/search_results_view.js index 7a8d2c45e5..6235ef799a 100644 --- a/lms/static/js/search/views/search_list_view.js +++ b/lms/static/js/search/base/views/search_results_view.js @@ -5,39 +5,39 @@ define([ 'underscore', 'backbone', 'gettext', - 'js/search/views/search_item_view' -], function ($, _, Backbone, gettext, SearchItemView) { +], function ($, _, Backbone, gettext) { 'use strict'; return Backbone.View.extend({ - el: '#courseware-search-results', - events: { - 'click .search-load-next': 'loadNext' - }, - spinner: '.icon', + // these should be defined by subclasses + el: '', + contentElement: '', + resultsTemplateId: '', + loadingTemplateId: '', + errorTemplateId: '', + events: {}, + spinner: '.search-load-next .icon', + SearchItemView: function () {}, initialize: function () { - this.courseName = this.$el.attr('data-course-name'); - this.$courseContent = $('#course-content'); - this.listTemplate = _.template($('#search_list-tpl').html()); - this.loadingTemplate = _.template($('#search_loading-tpl').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); + this.$contentElement = $(this.contentElement); + this.resultsTemplate = _.template($(this.resultsTemplateId).html()); + this.loadingTemplate = _.template($(this.loadingTemplateId).html()); + this.errorTemplate = _.template($(this.errorTemplateId).html()); }, render: function () { - this.$el.html(this.listTemplate({ + this.$el.html(this.resultsTemplate({ totalCount: this.collection.totalCount, totalCountMsg: this.totalCountMsg(), pageSize: this.collection.pageSize, hasMoreResults: this.collection.hasNextPage() })); this.renderItems(); - this.$courseContent.hide(); + this.$el.find(this.spinner).hide(); + this.$contentElement.hide(); this.$el.show(); return this; }, @@ -53,11 +53,12 @@ define([ }, renderItems: function () { - var items = this.collection.map(function (result) { - var item = new SearchItemView({ model: result }); + var latest = this.collection.latestModels(); + var items = latest.map(function (result) { + var item = new this.SearchItemView({ model: result }); return item.render().el; - }); - this.$el.find('.search-results').html(items); + }, this); + this.$el.find('ol').append(items); }, totalCountMsg: function () { @@ -67,25 +68,26 @@ define([ clear: function () { this.$el.hide().empty(); - this.$courseContent.show(); + this.$contentElement.show(); }, showLoadingMessage: function () { this.$el.html(this.loadingTemplate()); this.$el.show(); - this.$courseContent.hide(); + this.$contentElement.hide(); }, showErrorMessage: function () { this.$el.html(this.errorTemplate()); this.$el.show(); - this.$courseContent.hide(); + this.$contentElement.hide(); }, loadNext: function (event) { event && event.preventDefault(); this.$el.find(this.spinner).show(); this.trigger('next'); + return false; } }); diff --git a/lms/static/js/search/course/main.js b/lms/static/js/search/course/main.js new file mode 100644 index 0000000000..9d8bd2759c --- /dev/null +++ b/lms/static/js/search/course/main.js @@ -0,0 +1,22 @@ +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(); + +}); diff --git a/lms/static/js/search/course/search_app.js b/lms/static/js/search/course/search_app.js new file mode 100644 index 0000000000..992c5fdf56 --- /dev/null +++ b/lms/static/js/search/course/search_app.js @@ -0,0 +1,50 @@ +;(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); diff --git a/lms/static/js/search/course/views/search_form.js b/lms/static/js/search/course/views/search_form.js new file mode 100644 index 0000000000..8bee186a24 --- /dev/null +++ b/lms/static/js/search/course/views/search_form.js @@ -0,0 +1,14 @@ +;(function (define) { + +define([ + 'js/search/base/views/search_form' +], function (SearchForm) { + 'use strict'; + + return SearchForm.extend({ + el: '#courseware-search-bar' + }); + +}); + +})(define || RequireJS.define); diff --git a/lms/static/js/search/course/views/search_item_view.js b/lms/static/js/search/course/views/search_item_view.js new file mode 100644 index 0000000000..cd130b2240 --- /dev/null +++ b/lms/static/js/search/course/views/search_item_view.js @@ -0,0 +1,14 @@ +;(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); diff --git a/lms/static/js/search/course/views/search_results_view.js b/lms/static/js/search/course/views/search_results_view.js new file mode 100644 index 0000000000..917e7e960b --- /dev/null +++ b/lms/static/js/search/course/views/search_results_view.js @@ -0,0 +1,26 @@ +;(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); diff --git a/lms/static/js/search/dashboard/main.js b/lms/static/js/search/dashboard/main.js new file mode 100644 index 0000000000..0a6574caa0 --- /dev/null +++ b/lms/static/js/search/dashboard/main.js @@ -0,0 +1,19 @@ +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(); + +}); diff --git a/lms/static/js/search/dashboard/search_app.js b/lms/static/js/search/dashboard/search_app.js new file mode 100644 index 0000000000..d60d604750 --- /dev/null +++ b/lms/static/js/search/dashboard/search_app.js @@ -0,0 +1,54 @@ +;(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); diff --git a/lms/static/js/search/dashboard/views/search_form.js b/lms/static/js/search/dashboard/views/search_form.js new file mode 100644 index 0000000000..8c3a4e62e4 --- /dev/null +++ b/lms/static/js/search/dashboard/views/search_form.js @@ -0,0 +1,14 @@ +;(function (define) { + +define([ + 'js/search/base/views/search_form' +], function (SearchForm) { + 'use strict'; + + return SearchForm.extend({ + el: '#dashboard-search-bar' + }); + +}); + +})(define || RequireJS.define); diff --git a/lms/static/js/search/dashboard/views/search_item_view.js b/lms/static/js/search/dashboard/views/search_item_view.js new file mode 100644 index 0000000000..d577e2f1f0 --- /dev/null +++ b/lms/static/js/search/dashboard/views/search_item_view.js @@ -0,0 +1,14 @@ +;(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); diff --git a/lms/static/js/search/dashboard/views/search_results_view.js b/lms/static/js/search/dashboard/views/search_results_view.js new file mode 100644 index 0000000000..b94826e0cf --- /dev/null +++ b/lms/static/js/search/dashboard/views/search_results_view.js @@ -0,0 +1,33 @@ +;(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); diff --git a/lms/static/js/search/main.js b/lms/static/js/search/main.js deleted file mode 100644 index f97d3eb95c..0000000000 --- a/lms/static/js/search/main.js +++ /dev/null @@ -1,12 +0,0 @@ -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(); - -}); diff --git a/lms/static/js/search/search_app.js b/lms/static/js/search/search_app.js deleted file mode 100644 index bf69ac3930..0000000000 --- a/lms/static/js/search/search_app.js +++ /dev/null @@ -1,37 +0,0 @@ -;(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); diff --git a/lms/static/js/spec/search/search_spec.js b/lms/static/js/spec/search/search_spec.js index adc3260515..ebe315684a 100644 --- a/lms/static/js/spec/search/search_spec.js +++ b/lms/static/js/spec/search/search_spec.js @@ -4,136 +4,37 @@ define([ 'backbone', 'logger', 'js/common_helpers/template_helpers', - 'js/search/views/search_form', - 'js/search/views/search_item_view', - 'js/search/views/search_list_view', - 'js/search/models/search_result', - 'js/search/collections/search_collection', - 'js/search/search_router', - 'js/search/search_app' + 'js/search/base/models/search_result', + 'js/search/base/collections/search_collection', + 'js/search/base/routers/search_router', + 'js/search/course/views/search_item_view', + 'js/search/dashboard/views/search_item_view', + 'js/search/course/views/search_form', + '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( $, Sinon, Backbone, Logger, TemplateHelpers, - SearchForm, - SearchItemView, - SearchListView, SearchResult, SearchCollection, SearchRouter, - SearchApp + CourseSearchItemView, + DashSearchItemView, + CourseSearchForm, + DashSearchForm, + CourseSearchResultsView, + DashSearchResultsView, + CourseSearchApp, + DashSearchApp ) { '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 () { @@ -171,9 +72,16 @@ define([ this.server.restore(); }); - it('appends course_id to url', function () { - var collection = new SearchCollection([], { course_id: 'edx101' }); - expect(collection.url).toEqual('/search/edx101'); + it('sends a request without a course ID', function () { + var collection = new SearchCollection([]); + 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 () { @@ -195,6 +103,7 @@ define([ expect(this.onSearch).toHaveBeenCalled(); expect(this.collection.totalCount).toEqual(1); + expect(this.collection.latestModelsCount).toEqual(1); expect(this.collection.accessDeniedCount).toEqual(1); expect(this.collection.page).toEqual(0); expect(this.collection.first().attributes).toEqual(response.results[0].data); @@ -216,8 +125,9 @@ define([ }); it('sends correct paging parameters', function () { - this.collection.performSearch('search string'); + var searchString = 'search string'; var response = { total: 52, results: [] }; + this.collection.performSearch(searchString); this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]); this.server.respond(); this.collection.loadNextPage(); @@ -225,11 +135,9 @@ define([ spyOn($, 'ajax'); this.collection.loadNextPage(); expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url); - expect($.ajax.mostRecentCall.args[0].data).toEqual({ - search_string : 'search string', - page_size : this.collection.pageSize, - page_index : 2 - }); + expect($.ajax.mostRecentCall.args[0].data.search_string).toEqual(searchString); + expect($.ajax.mostRecentCall.args[0].data.page_size).toEqual(this.collection.pageSize); + expect($.ajax.mostRecentCall.args[0].data.page_index).toEqual(2); }); it('has next page', function () { @@ -266,18 +174,23 @@ define([ beforeEach(function () { this.collection.page = 2; this.collection.totalCount = 35; + this.collection.latestModelsCount = 5; }); it('resets state when performing new search', function () { this.collection.performSearch('search string'); + expect(this.collection.models.length).toEqual(0); expect(this.collection.page).toEqual(0); expect(this.collection.totalCount).toEqual(0); + expect(this.collection.latestModelsCount).toEqual(0); }); it('resets state when canceling a search', function () { this.collection.cancelSearch(); + expect(this.collection.models.length).toEqual(0); expect(this.collection.page).toEqual(0); expect(this.collection.totalCount).toEqual(0); + expect(this.collection.latestModelsCount).toEqual(0); }); }); @@ -285,167 +198,368 @@ define([ }); - describe('SearchListView', function () { - - beforeEach(function () { - setFixtures( - '
' + - '
' - ); - - 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' - ]); - - var MockCollection = Backbone.Collection.extend({ - hasNextPage: function (){} - }); - this.collection = new MockCollection(); - - // spy on these methods before they are bound to events - spyOn(SearchListView.prototype, 'render').andCallThrough(); - spyOn(SearchListView.prototype, 'renderNext').andCallThrough(); - spyOn(SearchListView.prototype, 'showErrorMessage').andCallThrough(); - - this.listView = new SearchListView({ collection: this.collection }); - }); - - it('shows loading message', function () { - this.listView.showLoadingMessage(); - expect($('#course-content')).toBeHidden(); - expect(this.listView.$el).toBeVisible(); - expect(this.listView.$el).not.toBeEmpty(); - }); - - it('shows error message', function () { - this.listView.showErrorMessage(); - expect($('#course-content')).toBeHidden(); - expect(this.listView.$el).toBeVisible(); - expect(this.listView.$el).not.toBeEmpty(); - }); - - it('returns to content', function () { - this.listView.clear(); - expect($('#course-content')).toBeVisible(); - expect(this.listView.$el).toBeHidden(); - expect(this.listView.$el).toBeEmpty(); - }); - - 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 () { - this.collection.reset(); - this.listView.render(); - expect(this.listView.$el).toContainHtml('no results'); - expect(this.listView.$el.find('ol')).not.toExist(); - }); - - it('renders search results', function () { - var searchResults = [{ - location: ['section', 'subsection', 'unit'], - url: '/some/url/to/content', - content_type: 'text', - excerpt: 'this is a short excerpt' - }]; - this.collection.set(searchResults); - this.collection.totalCount = 1; - - this.listView.render(); - expect(this.listView.$el.find('ol')[0]).toExist(); - expect(this.listView.$el.find('li').length).toEqual(1); - expect(this.listView.$el).toContainHtml('Search Results'); - expect(this.listView.$el).toContainHtml('this is a short excerpt'); - - searchResults[1] = searchResults[0] - this.collection.set(searchResults); - this.collection.totalCount = 2; - this.listView.renderNext(); - expect(this.listView.$el.find('.search-count')).toContainHtml('2'); - expect(this.listView.$el.find('li').length).toEqual(2); - }); - - it('shows a link to load more results', function () { - this.collection.totalCount = 123; - this.collection.hasNextPage = function () { return true; }; - this.listView.render(); - expect(this.listView.$el.find('a.search-load-next')[0]).toExist(); - - this.collection.totalCount = 123; - this.collection.hasNextPage = function () { return false; }; - this.listView.render(); - expect(this.listView.$el.find('a.search-load-next')[0]).not.toExist(); - }); - - it('triggers an event for next page', function () { - var onNext = jasmine.createSpy('onNext'); - this.listView.on('next', onNext); - this.collection.totalCount = 123; - this.collection.hasNextPage = function () { return true; }; - this.listView.render(); - this.listView.$el.find('a.search-load-next').click(); - expect(onNext).toHaveBeenCalled(); - }); - - it('shows a spinner when loading more results', function () { - this.collection.totalCount = 123; - this.collection.hasNextPage = function () { return true; }; - this.listView.render(); - this.listView.loadNext(); - - // Do we really need to check if a loading indicator exists? - CR - - // jasmine.Clock.useMock(1000); - // expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeVisible(); - this.listView.renderNext(); - // jasmine.Clock.useMock(1000); - // expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeHidden(); - }); - - }); - - describe('SearchRouter', function () { beforeEach(function () { this.router = new SearchRouter(); + this.onSearch = jasmine.createSpy('onSearch'); + 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([ + 'templates/search/course_search_item', + 'templates/search/dashboard_search_item' + ]); + + this.model = new SearchResult({ + location: ['section', 'subsection', 'unit'], + content_type: 'Video', + course_name: 'Course Name', + excerpt: 'A short excerpt.', + url: 'path/to/content' + }); + + this.seqModel = new SearchResult({ + location: ['section', 'subsection'], + content_type: 'Sequence', + 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(); + } + + 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); + }); + + describe('DashSearchItemView', function () { + beforeEach(function () { + beforeEachHelper.call(this, DashSearchItemView); + }); + 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); + }); + + }); + + + describe('SearchForm', function () { + + 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); + }); + + describe('DashSearchForm', function () { + beforeEach(function () { + loadFixtures('js/fixtures/search/dashboard_search_form.html'); + this.form = new DashSearchForm(); + 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); + }); + + }); + + + 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.resultsView.render(); + expect(this.resultsView.$el).toContainHtml('no results'); + expect(this.resultsView.$el.find('ol')).not.toExist(); + } + + function rendersSearchResults () { + var searchResults = [{ + location: ['section', 'subsection', 'unit'], + url: '/some/url/to/content', + content_type: 'text', + course_name: '', + excerpt: 'this is a short excerpt' + }]; + this.collection.set(searchResults); + this.collection.latestModelsCount = 1; + this.collection.totalCount = 1; + + this.resultsView.render(); + expect(this.resultsView.$el.find('ol')[0]).toExist(); + expect(this.resultsView.$el.find('li').length).toEqual(1); + expect(this.resultsView.$el).toContainHtml('Search Results'); + expect(this.resultsView.$el).toContainHtml('this is a short excerpt'); + + this.collection.set(searchResults); + this.collection.totalCount = 2; + this.resultsView.renderNext(); + expect(this.resultsView.$el.find('.search-count')).toContainHtml('2'); + expect(this.resultsView.$el.find('li').length).toEqual(2); + } + + function showsMoreResultsLink () { + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.resultsView.render(); + expect(this.resultsView.$el.find('a.search-load-next')[0]).toExist(); + + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return false; }; + this.resultsView.render(); + expect(this.resultsView.$el.find('a.search-load-next')[0]).not.toExist(); + } + + function triggersNextPageEvent () { + var onNext = jasmine.createSpy('onNext'); + this.resultsView.on('next', onNext); + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.resultsView.render(); + this.resultsView.$el.find('a.search-load-next').click(); + expect(onNext).toHaveBeenCalled(); + } + + function showsLoadMoreSpinner () { + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.resultsView.render(); + expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden(); + this.resultsView.loadNext(); + // toBeVisible does not work with inline + expect(this.resultsView.$el.find('a.search-load-next .icon')).toHaveCss({ 'display': 'inline' }); + this.resultsView.renderNext(); + expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden(); + } + + function beforeEachHelper(SearchResultsView) { + appendSetFixtures( + '
' + + '
' + + '
' + + '
' + ); + + 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' + ]); + + var MockCollection = Backbone.Collection.extend({ + 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 }); + } + + describe('CourseSearchResultsView', function () { + 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(); + }); + }); + }); describe('SearchApp', function () { - beforeEach(function () { - loadFixtures('js/fixtures/search_form.html'); - appendSetFixtures( - '
' + - '
' - ); - 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' - ]); + function showsLoadingMessage () { + $('.search-field').val('search string'); + $('.search-button').trigger('click'); + expect(this.$contentElement).toBeHidden(); + expect(this.$searchResults).toBeVisible(); + expect(this.$searchResults).not.toBeEmpty(); + } - this.server = Sinon.fakeServer.create(); + function performsSearch () { + $('.search-field').val('search string'); + $('.search-button').trigger('click'); this.server.respondWith([200, {}, JSON.stringify({ total: 1337, access_denied_count: 12, @@ -454,82 +568,203 @@ define([ location: ['section', 'subsection', 'unit'], url: '/some/url/to/content', content_type: 'text', - excerpt: 'this is a short excerpt' + excerpt: 'this is a short excerpt', + course_name: '' } }] })]); - - Backbone.history.stop(); - 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-button').trigger('click'); this.server.respond(); expect($('.search-info')).toExist(); - expect($('.search-results')).toBeVisible(); - }); + expect($('.search-result-list')).toBeVisible(); + expect(this.$searchResults.find('li').length).toEqual(1); + } - it ('updates navigation history on search', function () { + function showsErrorMessage () { + $('.search-field').val('search string'); + $('.search-button').trigger('click'); + this.server.respondWith([500, {}]); + this.server.respond(); + expect(this.$searchResults).toEqual($('#search_error-tpl')); + } + + function updatesNavigationHistory () { $('.search-field').val('edx'); $('.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 $('.search-field').val('search string'); $('.search-button').trigger('click'); // cancel search $('.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(); // there should be no results - expect($('#course-content')).toBeVisible(); - expect($('#courseware-search-results')).toBeHidden(); - }); + expect(this.$contentElement).toBeVisible(); + expect(this.$searchResults).toBeHidden(); + } - it ('clears results', function () { + function clearsResults () { $('.cancel-button').trigger('click'); - expect($('#course-content')).toBeVisible(); - expect($('#courseware-search-results')).toBeHidden(); - }); + expect(this.$contentElement).toBeVisible(); + 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-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(this.$searchResults.find('li').length).toEqual(1); expect($('.search-load-next')).toBeVisible(); $('.search-load-next').trigger('click'); var body = this.server.requests[1].requestBody; expect(body).toContain('search_string=query'); 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'); 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( + '
' + + '
' + ); + 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( + '
' + + '
' + ); + 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(); + }); + }); }); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 53badbe193..3ac6aa5f21 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -89,7 +89,8 @@ fixture_paths: - templates/verify_student - templates/file-upload.underscore - js/fixtures/edxnotes - - templates/courseware_search + - js/fixtures/search + - templates/search requirejs: paths: diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index beb628e653..c954ca1cf8 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -57,6 +57,11 @@ @import 'multicourse/edge'; @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 'shame'; // used for any bad-form/orphaned scss ## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index b3b8ebb2bb..c749f82f62 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -41,9 +41,9 @@ @import 'course/courseware/sidebar'; @import 'course/courseware/amplifier'; -## Import styles for courseware search +## Import styles for search % if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"): - @import 'course/courseware/courseware_search'; + @import 'search/_search'; % endif // course - modules diff --git a/lms/static/sass/course/courseware/_courseware_search.scss b/lms/static/sass/course/courseware/_courseware_search.scss deleted file mode 100644 index d91d416865..0000000000 --- a/lms/static/sass/course/courseware/_courseware_search.scss +++ /dev/null @@ -1,110 +0,0 @@ -.course-index .courseware-search-bar { - - @include box-sizing(border-box); - position: relative; - padding: 5px; - box-shadow: 0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset; - font-family: $sans-serif; - - .search-field { - @include box-sizing(border-box); - top: 5px; - width: 100%; - @include border-radius(4px); - background: $white-t1; - &.is-active { - background: $white; - } - } - - .search-button, .cancel-button { - @include box-sizing(border-box); - color: #888; - font-size: 14px; - font-weight: normal; - display: block; - position: absolute; - right: 12px; - top: 5px; - height: 35px; - line-height: 35px; - padding: 0; - border: none; - box-shadow: none; - background: transparent; - } - - .cancel-button { - display: none; - } - -} - - -.courseware-search-results { - - display: none; - padding: 40px; - - .search-info { - padding-bottom: lh(.75); - margin-bottom: lh(.50); - border-bottom: 1px solid $gray-l2; - .search-count { - float: right; - color: $gray; - } - } - - .search-results { - padding: 0; - } - - .search-results-item { - position: relative; - border-bottom: 1px solid $gray-l4; - list-style-type: none; - margin-bottom: lh(.75); - padding-bottom: lh(.75); - padding-right: 140px; - - .sri-excerpt { - color: $gray; - margin-bottom: lh(1); - } - .sri-type { - position: absolute; - right: 0; - top: 0; - color: $gray; - } - .sri-link { - position: absolute; - right: 0; - line-height: 1.6em; - bottom: lh(.75); - text-transform: uppercase; - } - .search-results-ellipsis { - @extend %t-copy-sub2; - color: $gray; - - &:after { - content: '\2026'; - } - } - } - - .search-load-next { - display: block; - text-transform: uppercase; - color: $base-font-color; - border: 2px solid $link-color; - @include border-radius(3px); - padding: 1rem; - .icon-spin { - display: none; - } - } - -} diff --git a/lms/static/sass/search/_search.scss b/lms/static/sass/search/_search.scss new file mode 100644 index 0000000000..dd757ae95f --- /dev/null +++ b/lms/static/sass/search/_search.scss @@ -0,0 +1,177 @@ +.search-bar { + + @include box-sizing(border-box); + position: relative; + padding: ($baseline/4); + + .search-field-wrapper { + position: relative; + } + + .search-field { + @extend %t-weight2; + @include box-sizing(border-box); + top: 5px; + width: 100%; + border-radius: 4px; + background: $white-t1; + &.is-active { + background: $white; + } + } + + .search-button, .cancel-button, + .search-button:hover, .cancel-button:hover { + @extend %t-icon6; + @extend %t-regular; + @include box-sizing(border-box); + @include right(12px); + display: block; + position: absolute; + top: 0; + border: none; + background: transparent; + padding: 0; + height: 35px; + color: $gray-l1; + box-shadow: none; + line-height: 35px; + text-shadow: none; + text-transform: none; + } + + .cancel-button { + display: none; + } + +} + +.search-results { + + display: none; + + .search-info { + border-bottom: 4px solid $border-color-l4; + padding-bottom: $baseline; + .search-count { + @include float(right); + color: $gray-l1; + } + } + + .search-result-list { + margin: 0; + padding: 0; + } + + .search-results-item { + @include padding-right(140px); + position: relative; + border-bottom: 1px solid $gray-l4; + padding: $baseline ($baseline/2); + list-style-type: none; + cursor: pointer; + + &:hover { + background: $gray-l6; + } + + .result-excerpt { + margin-bottom: $baseline; + } + .result-type { + @include right($baseline/2); + position: absolute; + bottom: $baseline; + font-size: 14px; + color: $gray-l1; + } + .result-course-name { + @include margin-right(1em); + font-size: 14px; + color: $gray-l1; + } + .result-location { + font-size: 14px; + color: $gray-l1; + } + .result-link { + @include right($baseline/2); + position: absolute; + top: $baseline; + line-height: 1.6em; + text-transform: uppercase; + } + .search-results-ellipsis { + @extend %t-copy-sub2; + color: $gray; + + &:after { + content: '\2026'; + } + } + } + + .search-load-next { + display: block; + border: 2px solid $link-color; + padding: 1rem; + border-radius: 3px; + 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); +} diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 1c081a959a..db1936dfc1 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -24,9 +24,9 @@ ${page_title_breadcrumbs(course_name())} % 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"]: % endfor @@ -149,16 +149,18 @@ ${fragment.foot_html()} % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'): - diff --git a/lms/templates/courseware_search/search_item.underscore b/lms/templates/courseware_search/search_item.underscore deleted file mode 100644 index 77efc34d2c..0000000000 --- a/lms/templates/courseware_search/search_item.underscore +++ /dev/null @@ -1,4 +0,0 @@ -
<%= excerpt %>
-<%- content_type %> -<%- location.join(' ▸ ') %> -<%= gettext("View") %> diff --git a/lms/templates/courseware_search/search_item_seq.underscore b/lms/templates/courseware_search/search_item_seq.underscore deleted file mode 100644 index 5261d89206..0000000000 --- a/lms/templates/courseware_search/search_item_seq.underscore +++ /dev/null @@ -1,4 +0,0 @@ -
- -<%- location.join(' ▸ ') %> -<%= gettext("View") %> diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index b33502fbfe..8a311a5655 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -27,6 +27,12 @@ <%static:include path="dashboard/${template_name}.underscore" /> % endfor + +% for template_name in ["dashboard_search_item", "dashboard_search_results", "search_loading", "search_error"]: + +% endfor <%block name="js_extra"> @@ -69,8 +75,8 @@

${_("Current Courses")}

- - + + % if len(course_enrollment_pairs) > 0: