Commit 1592ee2b by Andy Armstrong Committed by GitHub

Merge pull request #15173 from edx/andya/course-search-page

Enable course search for the unified course experience
parents 764c310c c0007a11
......@@ -46,6 +46,14 @@ class CourseHomePage(CoursePage):
courseware_page = CoursewarePage(self.browser, self.course_id)
courseware_page.wait_for_page()
def search_for_term(self, search_term):
"""
Search within a class for a particular term.
"""
self.q(css='.search-form > .search-input').fill(search_term)
self.q(css='.search-form > .search-button').click()
return CourseSearchResultsPage(self.browser, self.course_id)
class CourseOutlinePage(PageObject):
"""
......@@ -225,3 +233,22 @@ class CourseOutlinePage(PageObject):
promise_check_func=lambda: courseware_page.nav.is_on_section(section_title, subsection_title),
description="Waiting for course page with section '{0}' and subsection '{1}'".format(section_title, subsection_title)
)
class CourseSearchResultsPage(CoursePage):
"""
Course search page
"""
# url = "courses/{course_id}/search/?query={query_string}"
def is_browser_on_page(self):
return self.q(css='.page-content > .search-results').present
def __init__(self, browser, course_id):
super(CourseSearchResultsPage, self).__init__(browser, course_id)
self.course_id = course_id
@property
def search_results(self):
return self.q(css='.search-results-item')
......@@ -18,7 +18,7 @@ class DashboardSearchPage(PageObject):
@property
def search_results(self):
""" search results list showing """
return self.q(css='#dashboard-search-results')
return self.q(css='.search-results')
def is_browser_on_page(self):
""" did we find the search bar in the UI """
......
......@@ -285,7 +285,7 @@ class DiscussionNavigationTest(BaseDiscussionTestCase):
def test_breadcrumbs_clear_search(self):
self.thread_page.q(css=".search-input").fill("search text")
self.thread_page.q(css=".search-btn").click()
self.thread_page.q(css=".search-button").click()
# Verify that clicking the first breadcrumb clears your search
self.thread_page.q(css=".breadcrumbs .nav-item")[0].click()
......
......@@ -10,7 +10,7 @@ from nose.plugins.attrib import attr
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
from common.test.acceptance.pages.common.logout import LogoutPage
from common.test.acceptance.pages.lms.courseware_search import CoursewareSearchPage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
from common.test.acceptance.pages.studio.component_editor import ComponentVisibilityEditorView
......@@ -73,7 +73,7 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
email=self.cohort_default_student_email, no_login=True
).visit()
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
self.course_home_page = CourseHomePage(self.browser, self.course_id)
# Enable Cohorting and assign cohorts and content groups
self._auto_auth(self.staff_user["username"], self.staff_user["email"], True)
......@@ -105,11 +105,21 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
"""
Open staff page with assertion
"""
self.courseware_search_page.visit()
self.course_home_page.visit()
self.course_home_page.resume_course_from_header()
staff_page = StaffCoursewarePage(self.browser, self.course_id)
self.assertEqual(staff_page.staff_view_mode, 'Staff')
return staff_page
def _search_for_term(self, term):
"""
Search for term in course and return results.
"""
self.course_home_page.visit()
course_search_results_page = self.course_home_page.search_for_term(term)
results = course_search_results_page.search_results.html
return results[0] if len(results) > 0 else []
def populate_course_fixture(self, course_fixture):
"""
Populate the children of the test course fixture.
......@@ -195,48 +205,21 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
add_cohort_with_student("Cohort B", self.content_group_b, self.cohort_b_student_username)
cohort_management_page.wait_for_ajax()
def test_page_existence(self):
"""
Make sure that the page is accessible.
"""
self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
def test_cohorted_search_user_a_a_content(self):
"""
Test user can search content restricted to his cohort.
"""
self._auto_auth(self.cohort_a_student_username, self.cohort_a_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html in self.courseware_search_page.search_results.html[0]
search_results = self._search_for_term(self.group_a_html)
assert self.group_a_html in search_results
def test_cohorted_search_user_b_a_content(self):
"""
Test user can not search content restricted to his cohort.
"""
self._auto_auth(self.cohort_b_student_username, self.cohort_b_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html not in self.courseware_search_page.search_results.html[0]
def test_cohorted_search_user_default_ab_content(self):
"""
Test user not enrolled in any cohorts can't see any of restricted content.
"""
self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html not in self.courseware_search_page.search_results.html[0]
def test_cohorted_search_user_default_all_content(self):
"""
Test user can search public content if cohorts used on course.
"""
self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in self.courseware_search_page.search_results.html[0]
search_results = self._search_for_term(self.group_a_html)
assert self.group_a_html not in search_results
def test_cohorted_search_user_staff_all_content(self):
"""
......@@ -244,17 +227,14 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
"""
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
self._goto_staff_page().set_staff_view_mode('Staff')
self.courseware_search_page.search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_b_html)
assert self.group_b_html in self.courseware_search_page.search_results.html[0]
search_results = self._search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in search_results
search_results = self._search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html in search_results
search_results = self._search_for_term(self.group_a_html)
assert self.group_a_html in search_results
search_results = self._search_for_term(self.group_b_html)
assert self.group_b_html in search_results
def test_cohorted_search_user_staff_masquerade_student_content(self):
"""
......@@ -262,17 +242,14 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
"""
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
self._goto_staff_page().set_staff_view_mode('Learner')
self.courseware_search_page.search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html not in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html not in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_b_html)
assert self.group_b_html not in self.courseware_search_page.search_results.html[0]
search_results = self._search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in search_results
search_results = self._search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html not in search_results
search_results = self._search_for_term(self.group_a_html)
assert self.group_a_html not in search_results
search_results = self._search_for_term(self.group_b_html)
assert self.group_b_html not in search_results
def test_cohorted_search_user_staff_masquerade_cohort_content(self):
"""
......@@ -280,14 +257,11 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
"""
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
self._goto_staff_page().set_staff_view_mode('Learner in ' + self.content_group_a)
self.courseware_search_page.search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html in self.courseware_search_page.search_results.html[0]
self.courseware_search_page.clear_search()
self.courseware_search_page.search_for_term(self.group_b_html)
assert self.group_b_html not in self.courseware_search_page.search_results.html[0]
search_results = self._search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in search_results
search_results = self._search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html in search_results
search_results = self._search_for_term(self.group_a_html)
assert self.group_a_html in search_results
search_results = self._search_for_term(self.group_b_html)
assert self.group_b_html not in search_results
......@@ -7,7 +7,7 @@ from nose.plugins.attrib import attr
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...pages.lms.bookmarks import BookmarksPage
from ...pages.lms.course_home import CourseHomePage
from ...pages.lms.course_home import CourseHomePage, CourseSearchResultsPage
from ...pages.lms.courseware import CoursewarePage
from ..helpers import UniqueCourseTest, auto_auth, load_data_str
......@@ -134,3 +134,12 @@ class CourseHomeA11yTest(CourseHomeBaseTest):
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
course_home_page.a11y_audit.check_for_accessibility_errors()
def test_course_search_a11y(self):
"""
Test the accessibility of the search results page.
"""
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
course_search_results_page = course_home_page.search_for_term("Test Search")
course_search_results_page.a11y_audit.check_for_accessibility_errors()
......@@ -10,6 +10,7 @@ from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureD
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
from common.test.acceptance.pages.common.logout import LogoutPage
from common.test.acceptance.pages.common.utils import click_css
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.courseware_search import CoursewareSearchPage
from common.test.acceptance.pages.studio.container import ContainerPage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
......@@ -52,7 +53,8 @@ class CoursewareSearchTest(UniqueCourseTest):
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
super(CoursewareSearchTest, self).setUp()
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
self.course_home_page = CourseHomePage(self.browser, self.course_id)
self.studio_course_outline = StudioCourseOutlinePage(
self.browser,
......@@ -149,17 +151,31 @@ class CoursewareSearchTest(UniqueCourseTest):
(bool) True if search term is found in resulting content; False if not found
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.course_home_page.visit()
course_search_results_page = self.course_home_page.search_for_term(search_term)
if len(course_search_results_page.search_results.html) > 0:
search_string = course_search_results_page.search_results.html[0]
else:
search_string = ""
return search_term in search_string
# TODO: TNL-6546: Remove usages of sidebar search
def _search_for_content_in_sidebar(self, search_term, perform_auto_auth=True):
"""
Login and search for specific content in the legacy sidebar search
Arguments:
search_term - term to be searched for
perform_auto_auth - if False, skip auto_auth call.
Returns:
(bool) True if search term is found in resulting content; False if not found
"""
if perform_auto_auth:
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(search_term)
return search_term in self.courseware_search_page.search_results.html[0]
def test_page_existence(self):
"""
Make sure that the page is accessible.
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page.visit()
def test_search(self):
"""
Make sure that you can search for something.
......@@ -171,12 +187,18 @@ class CoursewareSearchTest(UniqueCourseTest):
# Do a search, there should be no results shown.
self.assertFalse(self._search_for_content(self.SEARCH_STRING))
# Do a search in the legacy sidebar, there should be no results shown.
self.assertFalse(self._search_for_content_in_sidebar(self.SEARCH_STRING, False))
# Publish in studio to trigger indexing.
self._studio_publish_content(0)
# Do the search again, this time we expect results.
self.assertTrue(self._search_for_content(self.SEARCH_STRING))
# Do the search again in the legacy sidebar, this time we expect results.
self.assertTrue(self._search_for_content_in_sidebar(self.SEARCH_STRING, False))
@flaky # TNL-5771
def test_reindex(self):
"""
......
......@@ -9,7 +9,7 @@ from nose.plugins.attrib import attr
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
from common.test.acceptance.pages.common.logout import LogoutPage
from common.test.acceptance.pages.lms.courseware_search import CoursewareSearchPage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
from common.test.acceptance.tests.helpers import create_user_partition_json, remove_file
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
......@@ -38,7 +38,7 @@ class SplitTestCoursewareSearchTest(ContainerBase):
super(SplitTestCoursewareSearchTest, self).setUp(is_staff=is_staff)
self.staff_user = self.user
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
self.course_home_page = CourseHomePage(self.browser, self.course_id)
self.studio_course_outline = StudioCourseOutlinePage(
self.browser,
self.course_info['org'],
......@@ -112,19 +112,12 @@ class SplitTestCoursewareSearchTest(ContainerBase):
)
)
def test_page_existence(self):
"""
Make sure that the page is accessible.
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page.visit()
def test_search_for_experiment_content_user_assigned_to_one_group(self):
"""
Test user can search for experiment content restricted to his group
when assigned to just one experiment group
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term("VISIBLE TO")
assert "1 result" in self.courseware_search_page.search_results.html[0]
self.course_home_page.visit()
course_search_results_page = self.course_home_page.search_for_term("VISIBLE TO")
assert "result-excerpt" in course_search_results_page.search_results.html[0]
......@@ -57,7 +57,7 @@
discussionBoardView.render();
threadListView = discussionBoardView.discussionThreadListView;
spyOn(threadListView, 'performSearch');
discussionBoardView.$el.find('.search-btn').click();
discussionBoardView.$el.find('.search-button').click();
expect(threadListView.performSearch).toHaveBeenCalled();
});
});
......
......@@ -26,7 +26,7 @@
'keydown .forum-nav-browse-filter-input': 'keyboardBinding',
'click .forum-nav-browse-menu-wrapper': 'ignoreClick',
'keydown .search-input': 'performSearch',
'click .search-btn': 'performSearch',
'click .search-button': 'performSearch',
'topic:selected': 'clearSearch'
},
......
......@@ -6,4 +6,4 @@
id="search"
placeholder="<%- gettext("Search all posts") %>"
/>
<button class="btn btn-small search-btn" type="button"><%- gettext("Search") %></button>
<button class="btn btn-small search-button" type="button"><%- gettext("Search") %></button>
......@@ -2230,6 +2230,7 @@ INSTALLED_APPS = (
# Features
'openedx.features.course_bookmarks',
'openedx.features.course_experience',
'openedx.features.course_search',
'openedx.features.enterprise_support',
)
......
../../openedx/features/course_search/static/course_search
\ No newline at end of file
(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_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);
......@@ -28,6 +28,7 @@ var options = {
sourceFiles: [
{pattern: 'coffee/src/**/!(*spec).js'},
{pattern: 'course_bookmarks/**/!(*spec).js'},
{pattern: 'course_search/**/!(*spec).js'},
{pattern: 'discussion/js/**/!(*spec).js'},
{pattern: 'js/**/!(*spec|djangojs).js'},
{pattern: 'lms/js/**/!(*spec).js'},
......
......@@ -19,6 +19,8 @@
*/
modules: getModulesList([
'course_bookmarks/js/course_bookmarks_factory',
'course_search/js/course_search_factory',
'course_search/js/dashboard_search_factory',
'discussion/js/discussion_board_factory',
'discussion/js/discussion_profile_page_factory',
'js/api_admin/catalog_preview_factory',
......@@ -32,8 +34,6 @@
'js/header_factory',
'js/learner_dashboard/program_details_factory',
'js/learner_dashboard/program_list_factory',
'js/search/course/course_search_factory',
'js/search/dashboard/dashboard_search_factory',
'js/student_account/logistration_factory',
'js/student_account/views/account_settings_factory',
'js/student_account/views/finish_auth_factory',
......
......@@ -676,6 +676,7 @@
'course_bookmarks/js/spec/bookmark_button_view_spec.js',
'course_bookmarks/js/spec/bookmarks_list_view_spec.js',
'course_bookmarks/js/spec/course_bookmarks_factory_spec.js',
'course_search/js/spec/course_search_spec.js',
'discussion/js/spec/discussion_board_factory_spec.js',
'discussion/js/spec/discussion_profile_page_factory_spec.js',
'discussion/js/spec/discussion_board_view_spec.js',
......@@ -749,7 +750,6 @@
'js/spec/markdown_editor_spec.js',
'js/spec/dateutil_factory_spec.js',
'js/spec/navigation_spec.js',
'js/spec/search/search_spec.js',
'js/spec/shoppingcart/shoppingcart_spec.js',
'js/spec/staff_debug_actions_spec.js',
'js/spec/student_account/access_spec.js',
......
......@@ -26,3 +26,4 @@
// Features
@import 'features/bookmarks';
@import 'features/course-experience';
@import 'features/course-search';
// Styles for course search results
.search-results {
.search-result-list {
list-style: none;
margin: 0;
padding: 0;
clear: both;
}
.search-results-title {
display: inline-block;
color: black;
font-size: 1.5rem;
line-height: 1.5;
}
.search-count {
@include float(right);
color: $lms-gray;
}
.search-results-item {
@include padding-right(140px);
position: relative;
border-top: 1px solid $border-color;
padding: $baseline ($baseline/2);
list-style-type: none;
cursor: pointer;
&:last-child {
border-bottom: 1px solid $border-color;
}
&:hover {
background: $lms-background-color;
}
}
.result-excerpt {
display: inline-block;
}
.result-location {
display: block;
color: $lms-gray;
font-size: 14px;
padding-top: ($baseline/2);
}
.result-link {
@include right($baseline/2);
position: absolute;
top: $baseline;
line-height: 1.6em;
}
.result-type {
@include right($baseline/2);
position: absolute;
color: $lms-gray;
font-size: 14px;
bottom: $baseline;
}
.search-load-next {
display: block;
margin-top: $baseline;
}
}
......@@ -62,12 +62,8 @@
vertical-align: middle;
}
.search-field {
transition: all $tmg-f2 ease-in-out;
border: 1px solid $lms-border-color;
border-radius: 3px;
padding: $baseline/4 $baseline*1.5;
font-family: inherit;
.search-input {
width: 12rem;
}
.action-search {
......
......@@ -40,6 +40,7 @@
vertical-align: text-bottom;
.form-actions {
@include margin-left($baseline/2);
display: inline-block;
}
......
......@@ -37,14 +37,6 @@ from openedx.features.course_experience import course_home_page_title, UNIFIED_C
</script>
% endfor
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
% for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="search/${template_name}.underscore" />
</script>
% endfor
% endif
% if include_special_exams is not UNDEFINED and include_special_exams:
% for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl">
......@@ -81,9 +73,12 @@ from openedx.features.course_experience import course_home_page_title, UNIFIED_C
<%include file="/mathjax_include.html" args="disable_fast_preview=True"/>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<%static:require_module module_name="js/search/course/course_search_factory" class_name="CourseSearchFactory">
<%static:require_module module_name="course_search/js/course_search_factory" class_name="CourseSearchFactory">
var courseId = $('.courseware-results').data('courseId');
CourseSearchFactory(courseId);
CourseSearchFactory({
courseId: courseId,
searchHeader: $('.search-bar')
});
</%static:require_module>
% endif
......@@ -131,7 +126,7 @@ ${HTML(fragment.foot_html())}
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form>
<form class="search-form">
<label for="course-search-input" class="sr">${_('Course Search')}</label>
<div class="search-field-wrapper">
<input id="course-search-input" type="text" class="search-field"/>
......
......@@ -28,12 +28,6 @@ from openedx.core.djangolib.markup import HTML, Text
<%static:include path="dashboard/${template_name}.underscore" />
</script>
% 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 name="js_extra">
......@@ -49,7 +43,7 @@ from openedx.core.djangolib.markup import HTML, Text
});
</script>
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<%static:require_module module_name="js/search/dashboard/dashboard_search_factory" class_name="DashboardSearchFactory">
<%static:require_module module_name="course_search/js/dashboard_search_factory" class_name="DashboardSearchFactory">
DashboardSearchFactory();
</%static:require_module>
% endif
......@@ -165,7 +159,7 @@ from openedx.core.djangolib.markup import HTML, Text
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<div id="dashboard-search-bar" class="search-bar dashboard-search-bar" role="search" aria-label="Dashboard">
<form>
<form class="search-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"/>
......
......@@ -56,7 +56,10 @@
id="search"
placeholder="Search all the things"
/>
<button class="btn btn-small search-btn" type="button">Search</button>
<button type="button" class="action action-clear" aria-label="Clear search">
<span class="icon fa fa-times-circle" aria-hidden="true"></span>
</button>
<button class="btn btn-small search-button" type="button">Search</button>
</form>
</div>
</div>
......
......@@ -636,6 +636,14 @@ urlpatterns += (
),
include('openedx.features.course_bookmarks.urls'),
),
# Course search
url(
r'^courses/{}/search/'.format(
settings.COURSE_ID_PATTERN,
),
include('openedx.features.course_search.urls'),
),
)
if settings.FEATURES["ENABLE_TEAMS"]:
......
"""
Defines URLs for the course experience.
Defines URLs for course bookmarks.
"""
from django.conf.urls import url
......
......@@ -28,16 +28,16 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
<div class="page-header-secondary">
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div class="page-header-search">
<form class="search-form" role="search">
<form class="search-form" role="search" action="${reverse('openedx.course_search.course_search_results', args=[course_key])}">
<label class="field-label sr-only" for="search" id="search-hint">${_('Search the course')}</label>
<input
class="field-input input-text search-input"
type="search"
name="search"
name="query"
id="search"
placeholder="${_('Search the course')}"
/>
<button class="btn btn-small search-btn" type="button">${_('Search')}</button>
<button class="btn btn-small search-button" type="submit">${_('Search')}</button>
</form>
</div>
% endif
......
......@@ -113,7 +113,6 @@ class CourseHomeFragmentView(EdxFragmentView):
'csrf': csrf(request)['csrf_token'],
'course': course,
'course_key': course_key,
'course': course,
'outline_fragment': outline_fragment,
'handouts_html': handouts_html,
'has_visited_course': has_visited_course,
......
<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</button>
<button type="button" class="cancel-button" title="Clear search">
<span class="icon fa fa-remove" aria-hidden="true"></span>
</button>
</form>
<div class="course-view container" id="course-container">
<div id="courseware-search-bar" class="search-bar" role="search" aria-label="Course">
<form class="search-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</button>
<button type="button" class="cancel-button" title="Clear search">
<span class="icon fa fa-remove" aria-hidden="true"></span>
</button>
</form>
</div>
<div class="search-results courseware-results">
<section id="course-content"></section>
</div>
<div class="course-view container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="My Bookmarks" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs">
<div class="breadcrumbs">
<span class="nav-item">
<a href="/course">Course</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">Search Results</span>
</div>
</div>
</nav>
</div>
<div class="page-header-secondary">
<div class="page-header-search">
<form class="search-form" role="search" action="/search">
<label class="field-label sr-only" for="search" id="search-hint">Search the course</label>
<input
class="field-input input-text search-field"
type="search"
name="query"
id="search"
value="query"
placeholder="Search the course"
/>
<button class="btn btn-small search-button" type="submit">Search</button>
</form>
</div>
</div>
</header>
<div class="page-content">
<main class="search-results">
</main>
</div>
</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">
<span class="icon fa fa-search" aria-hidden="true"></span>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<span class="icon fa fa-remove" aria-hidden="true"></span>
</button>
</div>
</form>
<div class="course-view container" id="course-container">
<div id="dashboard-search-bar" class="search-bar" role="search" aria-label="Dashboard">
<form class="search-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">
<span class="icon fa fa-search" aria-hidden="true"></span>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<span class="icon fa fa-remove" aria-hidden="true"></span>
</button>
</div>
</form>
</div>
<section id="dashboard-search-results" class="search-results dashboard-search-results"></section>
<section id="my-courses" tabindex="-1"></section>
</div>
(function(define) {
'use strict';
define([
'underscore',
'backbone',
'js/search/base/models/search_result'
], function(Backbone, SearchResult) {
'use strict';
'course_search/js/models/search_result'
], function(_, Backbone, SearchResult) {
return Backbone.Collection.extend({
model: SearchResult,
......@@ -26,7 +27,9 @@
},
performSearch: function(searchTerm) {
this.fetchXhr && this.fetchXhr.abort();
if (this.fetchXhr) {
this.fetchXhr.abort();
}
this.searchTerm = searchTerm || '';
this.resetState();
this.fetchXhr = this.fetch({
......@@ -36,17 +39,19 @@
page_index: 0
},
type: 'POST',
success: function(self, xhr) {
success: function(self) {
self.trigger('search');
},
error: function(self, xhr) {
error: function(self) {
self.trigger('error');
}
});
},
loadNextPage: function() {
this.fetchXhr && this.fetchXhr.abort();
if (this.fetchXhr) {
this.fetchXhr.abort();
}
this.fetchXhr = this.fetch({
data: {
search_string: this.searchTerm,
......@@ -54,11 +59,11 @@
page_index: this.page + 1
},
type: 'POST',
success: function(self, xhr) {
self.page += 1;
success: function(self) {
self.page += 1; // eslint-disable-line no-param-reassign
self.trigger('next');
},
error: function(self, xhr) {
error: function(self) {
self.trigger('error');
},
add: true,
......@@ -68,7 +73,9 @@
},
cancelSearch: function() {
this.fetchXhr && this.fetchXhr.abort();
if (this.fetchXhr) {
this.fetchXhr.abort();
}
this.resetState();
},
......@@ -101,4 +108,4 @@
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
'use strict';
define(['backbone', '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, SearchRouter, CourseSearchForm, SearchCollection, SearchResultsView) {
return function(courseId) {
define([
'underscore', 'backbone', 'course_search/js/search_router', 'course_search/js/views/search_form',
'course_search/js/collections/search_collection', 'course_search/js/views/course_search_results_view'
],
function(_, Backbone, SearchRouter, CourseSearchForm, SearchCollection, CourseSearchResultsView) {
return function(options) {
var courseId = options.courseId;
var requestedQuery = options.query;
var supportsActive = options.supportsActive;
var router = new SearchRouter();
var form = new CourseSearchForm();
var form = new CourseSearchForm({
el: options.searchHeader,
supportsActive: supportsActive
});
var collection = new SearchCollection([], {courseId: courseId});
var results = new SearchResultsView({collection: collection});
var results = new CourseSearchResultsView({collection: collection});
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function(query) {
......@@ -42,6 +50,11 @@
dispatcher.listenTo(collection, 'error', function() {
results.showErrorMessage();
});
// Perform a search if an initial query has been provided.
if (requestedQuery) {
router.trigger('search', requestedQuery);
}
};
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
'use strict';
define(['backbone', '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, SearchRouter, SearchForm, SearchCollection, SearchListView) {
define([
'underscore', 'backbone', 'course_search/js/search_router', 'course_search/js/views/search_form',
'course_search/js/collections/search_collection', 'course_search/js/views/dashboard_search_results_view'
],
function(_, Backbone, SearchRouter, SearchForm, SearchCollection, DashboardSearchResultsView) {
return function() {
var router = new SearchRouter();
var form = new SearchForm();
var form = new SearchForm({
el: $('#dashboard-search-bar')
});
var collection = new SearchCollection([]);
var results = new SearchListView({collection: collection});
var results = new DashboardSearchResultsView({collection: collection});
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function(query) {
......@@ -48,4 +52,4 @@
});
};
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
define(['backbone'], function(Backbone) {
'use strict';
'use strict';
define(['backbone'], function(Backbone) {
return Backbone.Model.extend({
defaults: {
location: [],
......@@ -11,4 +11,4 @@
}
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
define(['backbone'], function(Backbone) {
'use strict';
'use strict';
define(['backbone'], function(Backbone) {
return Backbone.Router.extend({
routes: {
'search/:query': 'search'
......@@ -11,4 +11,4 @@
}
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
......@@ -5,17 +5,16 @@ define([
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/page_helpers',
'common/js/spec_helpers/template_helpers',
'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/course_search_factory',
'js/search/dashboard/dashboard_search_factory'
'course_search/js/models/search_result',
'course_search/js/collections/search_collection',
'course_search/js/search_router',
'course_search/js/views/search_form',
'course_search/js/views/search_item_view',
'course_search/js/views/course_search_results_view',
'course_search/js/views/dashboard_search_results_view',
'course_search/js/course_search_factory',
'course_search/js/dashboard_search_factory',
'text!course_search/templates/course_search_item.underscore'
], function(
$,
Backbone,
......@@ -26,18 +25,17 @@ define([
SearchResult,
SearchCollection,
SearchRouter,
CourseSearchItemView,
DashSearchItemView,
CourseSearchForm,
DashSearchForm,
SearchForm,
SearchItemView,
CourseSearchResultsView,
DashSearchResultsView,
CourseSearchFactory,
DashboardSearchFactory
DashboardSearchFactory,
courseSearchItemTemplate
) {
'use strict';
describe('Search', function() {
describe('Course Search', function() {
beforeEach(function() {
PageHelpers.preventBackboneChangingUrl();
});
......@@ -86,7 +84,6 @@ define([
it('sends a request and parses the json result', function() {
var requests = AjaxHelpers.requests(this);
this.collection.performSearch('search string');
var response = {
total: 2,
access_denied_count: 1,
......@@ -99,6 +96,7 @@ define([
}
}]
};
this.collection.performSearch('search string');
AjaxHelpers.respondWithJson(requests, response);
expect(this.onSearch).toHaveBeenCalled();
......@@ -221,12 +219,7 @@ define([
describe('SearchItemView', function() {
function beforeEachHelper(SearchItemView) {
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item'
]);
beforeEach(function() {
this.model = new SearchResult({
location: ['section', 'subsection', 'unit'],
content_type: 'Video',
......@@ -243,31 +236,37 @@ define([
url: 'path/to/content'
});
this.item = new SearchItemView({model: this.model});
this.item = new SearchItemView({
model: this.model,
template: courseSearchItemTemplate
});
this.item.render();
this.seqItem = new SearchItemView({model: this.seqModel});
this.seqItem = new SearchItemView({
model: this.seqModel,
template: courseSearchItemTemplate
});
this.seqItem.render();
}
});
function rendersItem() {
it('rendersItem', function() {
expect(this.item.$el).toHaveAttr('role', 'region');
expect(this.item.$el).toHaveAttr('aria-label', 'search result');
expect(this.item.$el).toContainElement('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() {
it('rendersSequentialItem', function() {
expect(this.seqItem.$el).toHaveAttr('role', 'region');
expect(this.seqItem.$el).toHaveAttr('aria-label', 'search result');
expect(this.seqItem.$el).toContainElement('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() {
it('logsSearchItemViewEvent', function() {
this.model.collection = new SearchCollection([this.model], {course_id: 'edx101'});
this.item.render();
// Mock the redirect call
......@@ -277,27 +276,6 @@ define([
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);
});
});
......@@ -315,7 +293,7 @@ define([
$('.search-field').val(term);
this.form.doSearch(term);
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
expect($('.search-field').val()).toEqual(term);
expect($('.search-field').val()).toEqual(term.trim());
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
......@@ -350,26 +328,12 @@ define([
expect($('.search-button')).toBeVisible();
}
describe('CourseSearchForm', function() {
describe('SearchForm', 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();
loadFixtures('course_search/fixtures/course_content_page.html');
this.form = new SearchForm({
el: '.search-bar'
});
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
......@@ -401,7 +365,9 @@ define([
function returnsToContent() {
this.resultsView.clear();
expect(this.resultsView.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.resultsView.$contentElement).toHaveCss({
display: this.contentElementDisplayValue
});
expect(this.resultsView.$el).toBeHidden();
expect(this.resultsView.$el).toBeEmpty();
}
......@@ -467,28 +433,14 @@ define([
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'});
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(
'<div class="courseware-results"></div>' +
'<section id="course-content"></section>' +
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses" tabindex="-1"></section>'
);
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_loading',
'templates/search/search_error'
]);
var MockCollection = Backbone.Collection.extend({
hasNextPage: function() {},
latestModelsCount: 0,
......@@ -497,18 +449,20 @@ define([
return SearchCollection.prototype.latestModels.apply(this, arguments);
}
});
this.collection = new MockCollection();
this.resultsView = new SearchResultsView({collection: this.collection});
}
describe('CourseSearchResultsView', function() {
beforeEach(function() {
loadFixtures('course_search/fixtures/course_content_page.html');
beforeEachHelper.call(this, CourseSearchResultsView);
this.contentElementDisplayValue = 'table-cell';
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
it('returns to content', returnsToContent);
xit('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);
......@@ -518,6 +472,7 @@ define([
describe('DashSearchResultsView', function() {
beforeEach(function() {
loadFixtures('course_search/fixtures/dashboard_search_page.html');
beforeEachHelper.call(this, DashSearchResultsView);
this.contentElementDisplayValue = 'block';
});
......@@ -547,7 +502,9 @@ define([
function showsLoadingMessage() {
$('.search-field').val('search string');
$('.search-button').trigger('click');
expect(this.$contentElement).toBeHidden();
if (this.$contentElement) {
expect(this.$contentElement).toBeHidden();
}
expect(this.$searchResults).toBeVisible();
expect(this.$searchResults).not.toBeEmpty();
}
......@@ -599,13 +556,13 @@ define([
$('.cancel-button').trigger('click');
AjaxHelpers.skipResetRequest(requests);
// there should be no results
expect(this.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.$contentElement).toHaveCss({display: this.contentElementDisplayValue});
expect(this.$searchResults).toBeHidden();
}
function clearsResults() {
$('.cancel-button').trigger('click');
expect(this.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.$contentElement).toHaveCss({display: this.contentElementDisplayValue});
expect(this.$searchResults).toBeHidden();
}
......@@ -624,13 +581,14 @@ define([
}
}]
};
var body;
$('.search-field').val('query');
$('.search-button').trigger('click');
AjaxHelpers.respondWithJson(requests, response);
expect(this.$searchResults.find('li').length).toEqual(1);
expect($('.search-load-next')).toBeVisible();
$('.search-load-next').trigger('click');
var body = requests[1].requestBody;
body = requests[1].requestBody;
expect(body).toContain('search_string=query');
expect(body).toContain('page_index=1');
AjaxHelpers.respondWithJson(requests, response);
......@@ -644,32 +602,18 @@ define([
expect(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(
'<div class="courseware-results"></div>' +
'<section id="course-content"></section>'
);
loadTemplates.call(this);
var courseId = 'a/b/c';
CourseSearchFactory(courseId);
loadFixtures('course_search/fixtures/course_content_page.html');
CourseSearchFactory({
courseId: courseId,
searchHeader: $('.search-bar')
});
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#course-content');
this.contentElementDisplayValue = 'table-cell';
this.$searchResults = $('.courseware-results');
this.$searchResults = $('.search-results');
});
afterEach(function() {
......@@ -680,26 +624,21 @@ define([
it('performs search', performsSearch);
it('shows an error message', showsErrorMessage);
it('updates navigation history', updatesNavigationHistory);
it('cancels search request', cancelsSearchRequest);
it('clears results', clearsResults);
xit('cancels search request', cancelsSearchRequest);
xit('clears results', clearsResults);
it('loads next page', loadsNextPage);
it('navigates to search', navigatesToSearch);
});
describe('DashSearchApp', function() {
describe('DashboardSearchApp', function() {
beforeEach(function() {
loadFixtures('js/fixtures/search/dashboard_search_form.html');
appendSetFixtures(
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses" tabindex="-1"></section>'
);
loadTemplates.call(this);
loadFixtures('course_search/fixtures/dashboard_search_page.html');
DashboardSearchFactory();
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#my-courses');
this.contentElementDisplayValue = 'block';
this.$searchResults = $('#dashboard-search-results');
this.$searchResults = $('.search-results');
});
afterEach(function() {
......@@ -738,6 +677,30 @@ define([
expect(this.$searchResults).toBeEmpty();
});
});
describe('Course Search Results Page', function() {
beforeEach(function() {
var courseId = 'a/b/c';
loadFixtures('course_search/fixtures/course_search_results_page.html');
CourseSearchFactory({
courseId: courseId,
searchHeader: $('.page-header-search')
});
spyOn(Backbone.history, 'navigate');
this.$contentElement = null; // The search results page does not show over a content element
this.contentElementDisplayValue = 'table-cell';
this.$searchResults = $('.search-results');
});
afterEach(function() {
Backbone.history.stop();
});
it('shows loading message on search', showsLoadingMessage);
it('performs search', performsSearch);
it('shows an error message', showsErrorMessage);
it('loads next page', loadsNextPage);
});
});
});
});
(function(define) {
define([
'js/search/base/views/search_results_view',
'js/search/course/views/search_item_view'
], function(SearchResultsView, CourseSearchItemView) {
'use strict';
'use strict';
define([
'course_search/js/views/search_results_view',
'text!course_search/templates/course_search_results.underscore',
'text!course_search/templates/course_search_item.underscore'
], function(
SearchResultsView,
courseSearchResultsTemplate,
courseSearchItemTemplate
) {
return SearchResultsView.extend({
el: '.courseware-results',
el: '.search-results',
contentElement: '#course-content',
coursewareResultsWrapperElement: '.courseware-results-wrapper',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
resultsTemplate: courseSearchResultsTemplate,
itemTemplate: courseSearchItemTemplate,
events: {
'click .search-load-next': 'loadNext'
},
SearchItemView: CourseSearchItemView,
clear: function() {
SearchResultsView.prototype.clear.call(this);
......@@ -31,4 +33,4 @@
});
});
})(define || RequireJS.define);
}(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';
'use strict';
define([
'course_search/js/views/search_results_view',
'text!course_search/templates/dashboard_search_results.underscore',
'text!course_search/templates/dashboard_search_item.underscore'
], function(
SearchResultsView,
dashboardSearchResultsTemplate,
dashboardSearchItemTemplate
) {
return SearchResultsView.extend({
el: '#dashboard-search-results',
el: '.search-results',
contentElement: '#my-courses, #profile-sidebar',
resultsTemplateId: '#dashboard_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
resultsTemplate: dashboardSearchResultsTemplate,
itemTemplate: dashboardSearchItemTemplate,
events: {
'click .search-load-next': 'loadNext',
'click .search-back-to-courses': 'backToCourses'
},
SearchItemView: DashSearchItemView,
backToCourses: function() {
this.clear();
......@@ -26,4 +28,4 @@
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
define(['jquery', 'backbone'], function($, Backbone) {
'use strict';
'use strict';
define(['jquery', 'backbone'], function($, Backbone) {
return Backbone.View.extend({
el: '',
events: {
'submit form': 'submitForm',
'submit .search-form': 'submitForm',
'click .cancel-button': 'clearSearch'
},
initialize: function() {
initialize: function(options) {
this.$searchField = this.$el.find('.search-field');
this.$searchButton = this.$el.find('.search-button');
this.$cancelButton = this.$el.find('.cancel-button');
this.supportsActive = options.supportsActive === undefined ? true : options.supportsActive;
},
submitForm: function(event) {
......@@ -22,19 +23,17 @@
},
doSearch: function(term) {
var trimmedTerm;
if (term) {
this.$searchField.val(term);
trimmedTerm = term.trim();
this.$searchField.val(trimmedTerm);
} else {
trimmedTerm = this.$searchField.val().trim();
}
else {
term = this.$searchField.val();
}
var trimmed = $.trim(term);
if (trimmed) {
if (trimmedTerm) {
this.setActiveStyle();
this.trigger('search', trimmed);
}
else {
this.trigger('search', trimmedTerm);
} else {
this.clearSearch();
}
},
......@@ -50,17 +49,20 @@
},
setActiveStyle: function() {
this.$searchField.addClass('is-active');
this.$searchButton.hide();
this.$cancelButton.show();
if (this.supportsActive) {
this.$searchField.addClass('is-active');
this.$searchButton.hide();
this.$cancelButton.show();
}
},
setInitialStyle: function() {
this.$searchField.removeClass('is-active');
this.$searchButton.show();
this.$cancelButton.hide();
if (this.supportsActive) {
this.$searchField.removeClass('is-active');
this.$searchButton.show();
this.$cancelButton.hide();
}
}
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
'use strict';
define([
'jquery',
'underscore',
'backbone',
'gettext',
'logger'
], function($, _, Backbone, gettext, Logger) {
'use strict';
'logger',
'edx-ui-toolkit/js/utils/html-utils'
], function($, _, Backbone, gettext, Logger, HtmlUtils) {
return Backbone.View.extend({
tagName: 'li',
templateId: '',
className: 'search-results-item',
attributes: {
'role': 'region',
role: 'region',
'aria-label': 'search result'
},
events: {
'click': 'logSearchItem'
click: 'logSearchItem'
},
initialize: function() {
this.tpl = _.template($(this.templateId).html());
initialize: function(options) {
this.template = options.template;
},
render: function() {
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
// 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));
data.excerptHtml = HtmlUtils.HTML(data.excerpt);
delete data.excerpt;
HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.template)(data));
return this;
},
......@@ -47,7 +50,6 @@
},
logSearchItem: function(event) {
event.preventDefault();
var self = this;
var target = this.model.id;
var link = this.model.get('url');
......@@ -56,10 +58,13 @@
var pageSize = collection.pageSize;
var searchTerm = collection.searchTerm;
var index = collection.indexOf(this.model);
event.preventDefault();
Logger.log('edx.course.search.result_selected', {
'search_term': searchTerm,
'result_position': (page * pageSize + index),
'result_link': target
search_term: searchTerm,
result_position: (page * pageSize) + index,
result_link: target
}).always(function() {
self.redirect(link);
});
......@@ -67,4 +72,4 @@
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
(function(define) {
'use strict';
define([
'jquery',
'underscore',
'backbone',
'gettext'
], function($, _, Backbone, gettext) {
'use strict';
'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'course_search/js/views/search_item_view',
'text!course_search/templates/search_loading.underscore',
'text!course_search/templates/search_error.underscore'
], function($, _, Backbone, HtmlUtils, StringUtils, SearchItemView, searchLoadingTemplate, searchErrorTemplate) {
return Backbone.View.extend({
// these should be defined by subclasses
// these should be defined by subclasses
el: '',
contentElement: '',
resultsTemplateId: '',
loadingTemplateId: '',
errorTemplateId: '',
resultsTemplate: null,
itemTemplate: null,
loadingTemplate: searchLoadingTemplate,
errorTemplate: searchErrorTemplate,
events: {},
spinner: '.search-load-next .icon',
SearchItemView: function() {},
initialize: function() {
this.$contentElement = $(this.contentElement);
this.resultsTemplate = _.template($(this.resultsTemplateId).html());
this.loadingTemplate = _.template($(this.loadingTemplateId).html());
this.errorTemplate = _.template($(this.errorTemplateId).html());
this.$contentElement = this.contentElement ? $(this.contentElement) : $([]);
},
render: function() {
this.$el.html(this.resultsTemplate({
HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.resultsTemplate)({
totalCount: this.collection.totalCount,
totalCountMsg: this.totalCountMsg(),
pageSize: this.collection.pageSize,
......@@ -40,10 +41,10 @@
},
renderNext: function() {
// total count may have changed
// total count may have changed
this.$el.find('.search-count').text(this.totalCountMsg());
this.renderItems();
if (! this.collection.hasNextPage()) {
if (!this.collection.hasNextPage()) {
this.$el.find('.search-load-next').remove();
}
this.$el.find(this.spinner).hide();
......@@ -52,15 +53,21 @@
renderItems: function() {
var latest = this.collection.latestModels();
var items = latest.map(function(result) {
var item = new this.SearchItemView({model: result});
var item = new SearchItemView({
model: result,
template: this.itemTemplate
});
return item.render().el;
}, this);
// safe-lint: disable=javascript-jquery-append
this.$el.find('ol').append(items);
},
totalCountMsg: function() {
var fmt = ngettext('%s result', '%s results', this.collection.totalCount);
return interpolate(fmt, [this.collection.totalCount]);
var fmt = ngettext('{total_results} result', '{total_results} results', this.collection.totalCount);
return StringUtils.interpolate(fmt, {
total_results: this.collection.totalCount
});
},
clear: function() {
......@@ -74,27 +81,26 @@
},
showLoadingMessage: function() {
this.doCleanup();
this.$el.html(this.loadingTemplate());
// Empty any previous loading/error message
$('#loading-message').html('');
$('#error-message').html('');
// Show the loading message
HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.loadingTemplate)());
// Show the results
this.showResults();
},
showErrorMessage: function() {
this.$el.html(this.errorTemplate());
HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.errorTemplate)());
this.showResults();
},
doCleanup: function() {
// Empty any loading/error message and empty the el
// Bookmarks share the same container element, So we are doing
// this to ensure that elements are in clean/initial state
$('#loading-message').html('');
$('#error-message').html('');
this.$el.html('');
},
loadNext: function(event) {
event && event.preventDefault();
if (event) {
event.preventDefault();
}
this.$el.find(this.spinner).show();
this.trigger('next');
return false;
......@@ -102,4 +108,4 @@
});
});
})(define || RequireJS.define);
}(define || RequireJS.define));
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <span class="icon fa fa-arrow-right" aria-hidden="true"></span></a>
<div class="result-excerpt"><%= HtmlUtils.ensureHtml(excerptHtml) %></div>
<a class="result-link" href="<%- url %>"><%- gettext("View") %> <span class="icon fa fa-arrow-right" aria-hidden="true"></span></a>
<span class="result-location"><%- location.join(' ▸ ') %></span>
<span class="result-type"><%- content_type %></span>
<div class="search-info">
<%= gettext("Search Results") %>
<div class="search-count"><%= totalCountMsg %></div>
<h2 class="search-results-title"><%- gettext("Search Results") %></h2>
<div class="search-count"><%- totalCountMsg %></div>
</div>
<% if (totalCount > 0 ) { %>
......@@ -9,17 +9,20 @@
<% 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
) %>
<%-
StringUtils.interpolate(
ngettext("Load next {num_items} result", "Load next {num_items} results", pageSize),
{
num_items: pageSize
}
)
%>
<span class="icon fa fa-spinner fa-spin" aria-hidden="true"></span>
</a>
<% } %>
<% } else { %>
<p><%= gettext("Sorry, no results were found.") %></p>
<p><%- gettext("Sorry, no results were found.") %></p>
<% } %>
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <span class="icon fa fa-arrow-right" aria-hidden="true"></span></a>
<div class="result-excerpt"><%= HtmlUtils.ensureHtml(excerptHtml) %></div>
<a class="result-link" href="<%- url %>"><%- gettext("View") %> <span class="icon fa fa-arrow-right" aria-hidden="true"></span></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>
<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 ) { %>
......@@ -10,17 +10,20 @@
<% 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
) %>
<%-
StringUtils.interpolate(
ngettext("Load next {num_items} result", "Load next {num_items} results", pageSize),
{
num_items: pageSize
}
)
%>
<span class="icon fa fa-spinner fa-spin" aria-hidden="true"></span>
</a>
<% } %>
<% } else { %>
<p><%= gettext("Sorry, no results were found.") %></p>
<p><%- gettext("Sorry, no results were found.") %></p>
<% } %>
<span class="icon fa fa-spinner fa-spin" aria-hidden="true"></span> <%= gettext("Loading") %>
<span class="icon fa fa-spinner fa-spin" aria-hidden="true"></span> <%- gettext("Loading") %>
## mako
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
import json
import waffle
from django.conf import settings
from django.utils.translation import ugettext as _
from django.template.defaultfilters import escapejs
from django.core.urlresolvers import reverse
from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import course_home_page_title
%>
<%block name="content">
<div class="course-view container" id="course-container">
<header class="page-header has-secondary">
<div class="page-header-main">
<nav aria-label="${_('My Bookmarks')}" class="sr-is-focusable" tabindex="-1">
<div class="has-breadcrumbs">
<div class="breadcrumbs">
<span class="nav-item">
<a href="${course_url}">${course_home_page_title(course)}</a>
</span>
<span class="icon fa fa-angle-right" aria-hidden="true"></span>
<span class="nav-item">${_('Search Results')}</span>
</div>
</div>
</nav>
</div>
<div class="page-header-secondary">
<div class="page-header-search">
<form class="search-form" role="search">
<label class="field-label sr-only" for="search" id="search-hint">${_('Search the course')}</label>
<input
class="field-input input-text search-field"
type="search"
name="query"
id="search"
value="${query}"
placeholder="${_('Search the course')}"
/>
<button class="btn btn-small search-button" type="submit">${_('Search')}</button>
</form>
</div>
</div>
</header>
<div class="page-content">
<main role="main" class="search-results" id="main" tabindex="-1">
</main>
</div>
</div>
</%block>
<%block name="js_extra">
<%static:require_module module_name="course_search/js/course_search_factory" class_name="CourseSearchFactory">
var courseId = '${course_key | n, js_escaped_string}';
CourseSearchFactory({
courseId: courseId,
searchHeader: $('.page-header-search'),
supportsActive: false,
query: '${query | n, js_escaped_string}'
});
</%static:require_module>
</%block>
"""
Defines URLs for course search.
"""
from django.conf.urls import url
from views.course_search import CourseSearchView, CourseSearchFragmentView
urlpatterns = [
url(
r'^$',
CourseSearchView.as_view(),
name='openedx.course_search.course_search_results',
),
url(
r'^home_fragment$',
CourseSearchFragmentView.as_view(),
name='openedx.course_search.course_search_results_fragment_view',
),
]
"""
Views for the course search page.
"""
from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.template.loader import render_to_string
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from courseware.courses import get_course_overview_with_access
from lms.djangoapps.courseware.views.views import CourseTabView
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.features.course_experience import default_course_url_name
from util.views import ensure_valid_course_key
from web_fragments.fragment import Fragment
class CourseSearchView(CourseTabView):
"""
The home page for a course.
"""
@method_decorator(login_required)
@method_decorator(ensure_csrf_cookie)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
@method_decorator(ensure_valid_course_key)
def get(self, request, course_id, **kwargs):
"""
Displays the home page for the specified course.
"""
return super(CourseSearchView, self).get(request, course_id, 'courseware', **kwargs)
def render_to_fragment(self, request, course=None, tab=None, **kwargs):
course_id = unicode(course.id)
home_fragment_view = CourseSearchFragmentView()
return home_fragment_view.render_to_fragment(request, course_id=course_id, **kwargs)
class CourseSearchFragmentView(EdxFragmentView):
"""
A fragment to render the home page for a course.
"""
def render_to_fragment(self, request, course_id=None, **kwargs):
"""
Renders the course's home page as a fragment.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_overview_with_access(request.user, 'load', course_key, check_if_enrolled=True)
course_url_name = default_course_url_name(request)
course_url = reverse(course_url_name, kwargs={'course_id': unicode(course.id)})
# Render the course home fragment
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'course_key': course_key,
'course_url': course_url,
'query': request.GET.get('query', ''),
'disable_courseware_js': True,
'uses_pattern_library': True,
}
html = render_to_string('course_search/course-search-fragment.html', context)
return Fragment(html)
......@@ -29,12 +29,6 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
<%static:include path="dashboard/${template_name}.underscore" />
</script>
% 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 name="js_extra">
......@@ -50,7 +44,7 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
});
</script>
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<%static:require_module module_name="js/search/dashboard/dashboard_search_factory" class_name="DashboardSearchFactory">
<%static:require_module module_name="course_search/js/dashboard_search_factory" class_name="DashboardSearchFactory">
DashboardSearchFactory();
</%static:require_module>
% endif
......@@ -164,7 +158,7 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<div id="dashboard-search-bar" class="search-bar dashboard-search-bar" role="search" aria-label="Dashboard">
<form>
<form class="search-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"/>
......
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