Commit 1eeca4d8 by Martyn James

Merge pull request #7522 from edx/feature/dashboard-search

Feature/dashboard search
parents 960ec06f 71feb068
"""
Dashboard search
"""
from bok_choy.page_object import PageObject
from . import BASE_URL
class DashboardSearchPage(PageObject):
"""
Dashboard page featuring a search form
"""
search_bar_selector = '#dashboard-search-bar'
url = "{base}/dashboard".format(base=BASE_URL)
@property
def search_results(self):
""" search results list showing """
return self.q(css='#dashboard-search-results')
def is_browser_on_page(self):
""" did we find the search bar in the UI """
return self.q(css=self.search_bar_selector).present
def enter_search_term(self, text):
""" enter the search term into the box """
self.q(css=self.search_bar_selector + ' input[type="text"]').fill(text)
def search(self):
""" execute the search """
self.q(css=self.search_bar_selector + ' [type="submit"]').click()
self.wait_for_element_visibility('.search-info', 'Search results are shown')
def search_for_term(self, text):
"""
Search and return results
"""
self.enter_search_term(text)
self.search()
......@@ -33,6 +33,7 @@ class TabNavPage(PageObject):
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):
......
"""
Test dashboard search
"""
import os
import json
from bok_choy.web_app_test import WebAppTest
from ..helpers import generate_course_key
from ...pages.common.logout import LogoutPage
from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage
from ...pages.lms.dashboard_search import DashboardSearchPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
class DashboardSearchTest(WebAppTest):
"""
Test dashboard search.
"""
USERNAME = 'STUDENT_TESTER'
EMAIL = 'student101@example.com'
STAFF_USERNAME = "STAFF_TESTER"
STAFF_EMAIL = "staff101@example.com"
TEST_INDEX_FILENAME = "test_root/index_file.dat"
def setUp(self):
"""
Create the search page and courses to search.
"""
# create test file in which index for this test will live
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
json.dump({}, index_file)
super(DashboardSearchTest, self).setUp()
self.dashboard = DashboardSearchPage(self.browser)
self.courses = {
'A': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_A',
'display_name': 'Test Course A '
},
'B': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_B',
'display_name': 'Test Course B '
},
'C': {
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run_C',
'display_name': 'Test Course C '
}
}
# generate course fixtures and outline pages
self.course_outlines = {}
self.course_fixtures = {}
for key, course_info in self.courses.iteritems():
course_outline = CourseOutlinePage(
self.browser,
course_info['org'],
course_info['number'],
course_info['run']
)
course_fix = CourseFixture(
course_info['org'],
course_info['number'],
course_info['run'],
course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Section 1').add_children(
XBlockFixtureDesc('sequential', 'Subsection 1').add_children(
XBlockFixtureDesc('problem', 'dashboard search')
)
)
).add_children(
XBlockFixtureDesc('chapter', 'Section 2').add_children(
XBlockFixtureDesc('sequential', 'Subsection 2')
)
).install()
self.course_outlines[key] = course_outline
self.course_fixtures[key] = course_fix
def tearDown(self):
"""
Remove index file
"""
super(DashboardSearchTest, self).tearDown()
os.remove(self.TEST_INDEX_FILENAME)
def _auto_auth(self, username, email, staff):
"""
Logout and login with given credentials.
"""
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, username=username, email=email, staff=staff).visit()
def _studio_add_content(self, course_outline, html_content):
"""
Add content to first section on studio course page.
"""
# create a unit in course outline
course_outline.visit()
subsection = course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
subsection.add_unit()
# got to unit and create an HTML component and save (not publish)
unit_page = ContainerPage(self.browser, None)
unit_page.wait_for_page()
add_html_component(unit_page, 0)
unit_page.wait_for_element_presence('.edit-button', 'Edit button is visible')
click_css(unit_page, '.edit-button', 0, require_notification=False)
unit_page.wait_for_element_visibility('.modal-editor', 'Modal editor is visible')
type_in_codemirror(unit_page, 0, html_content)
click_css(unit_page, '.action-save', 0)
def _studio_publish_content(self, course_outline):
"""
Publish content in first section on studio course page.
"""
course_outline.visit()
subsection = course_outline.section_at(0).subsection_at(0)
subsection.expand_subsection()
unit = subsection.unit_at(0)
unit.publish()
def test_page_existence(self):
"""
Make sure that the page exists.
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
def test_search(self):
"""
Make sure that you can search courses.
"""
search_string = "dashboard"
html_content = "dashboard search"
# Enroll student in courses A & B, but not C
for course_info in [self.courses['A'], self.courses['B']]:
course_key = generate_course_key(
course_info['org'],
course_info['number'],
course_info['run']
)
AutoAuthPage(
self.browser,
username=self.USERNAME,
email=self.EMAIL,
course_id=course_key
).visit()
# Create content in studio without publishing.
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
self._studio_add_content(self.course_outlines['A'], html_content)
self._studio_add_content(self.course_outlines['B'], html_content)
self._studio_add_content(self.course_outlines['C'], html_content)
# Do a search, there should be no results shown.
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
self.dashboard.search_for_term(search_string)
assert search_string not in self.dashboard.search_results.html[0]
# Publish in studio to trigger indexing.
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
self._studio_publish_content(self.course_outlines['A'])
self._studio_publish_content(self.course_outlines['B'])
self._studio_publish_content(self.course_outlines['C'])
# Do the search again, this time we expect results from courses A & B, but not C
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.dashboard.visit()
self.dashboard.search_for_term(search_string)
assert self.dashboard.search_results.html[0].count(search_string) == 2
assert self.dashboard.search_results.html[0].count(self.courses['A']['display_name']) == 1
assert self.dashboard.search_results.html[0].count(self.courses['B']['display_name']) == 1
......@@ -179,7 +179,7 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
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"
......
......@@ -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"
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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"
......
"""
This file contains implementation override of SearchFilterGenerator which will allow
* Filter by all courses in which the user is enrolled in
"""
from student.models import CourseEnrollment
from search.filter_generator import SearchFilterGenerator
class LmsSearchFilterGenerator(SearchFilterGenerator):
""" SearchFilterGenerator for LMS Search """
def field_dictionary(self, **kwargs):
""" add course if provided otherwise add courses in which the user is enrolled in """
field_dictionary = super(LmsSearchFilterGenerator, self).field_dictionary(**kwargs)
if not kwargs.get('user'):
field_dictionary['course'] = []
elif not kwargs.get('course_id'):
user_enrollments = CourseEnrollment.enrollments_for_user(kwargs['user'])
field_dictionary['course'] = [unicode(enrollment.course_id) for enrollment in user_enrollments]
return field_dictionary
......@@ -20,6 +20,7 @@ class LmsSearchResultProcessor(SearchResultProcessor):
""" SearchResultProcessor for LMS Search """
_course_key = None
_course_name = None
_usage_key = None
_module_store = None
_module_temp_dictionary = {}
......@@ -62,6 +63,16 @@ class LmsSearchResultProcessor(SearchResultProcessor):
)
@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):
"""
Blend "location" property into the resultset, so that the path to the found component can be shown within the UI
......
"""
Tests for the lms_filter_generator
"""
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator
class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase):
""" Test case class to test search result processor """
def build_courses(self):
"""
Build up a course tree with multiple test courses
"""
self.courses = [
CourseFactory.create(
org='ElasticsearchFiltering',
course='ES101F',
run='test_run',
display_name='Elasticsearch Filtering test course',
),
CourseFactory.create(
org='FilterTest',
course='FT101',
run='test_run',
display_name='FilterTest test course',
)
]
def setUp(self):
super(LmsSearchFilterGeneratorTestCase, self).setUp()
self.build_courses()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
for course in self.courses:
CourseEnrollment.enroll(self.user, course.location.course_key)
def test_course_id_not_provided(self):
"""
Tests that we get the list of IDs of courses the user is enrolled in when the course ID is null or not provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user)
self.assertTrue('start_date' in filter_dictionary)
self.assertIn(unicode(self.courses[0].id), field_dictionary['course'])
self.assertIn(unicode(self.courses[1].id), field_dictionary['course'])
def test_course_id_provided(self):
"""
Tests that we get the course ID when the course ID is provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters(
user=self.user,
course_id=unicode(self.courses[0].id)
)
self.assertTrue('start_date' in filter_dictionary)
self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
def test_user_not_provided(self):
"""
Tests that we get empty list of courses in case the user is not provided
"""
field_dictionary, filter_dictionary = LmsSearchFilterGenerator.generate_field_filters()
self.assertTrue('start_date' in filter_dictionary)
self.assertEqual(0, len(field_dictionary['course']))
......@@ -85,6 +85,17 @@ class LmsSearchResultProcessorTestCase(ModuleStoreTestCase):
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(
{
......
<div id="courseware-search-bar" class="search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">Course Search</label>
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
search <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</form>
</div>
<div id="dashboard-search-bar" class="search-bar" role="search" aria-label="Dashboard">
<form>
<label for="dashboard-search-input">Search Your Courses</label>
<div>
<input id="dashboard-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">
<i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
<div id="courseware-search-bar" class="search-container">
<form role="search-form">
<input type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">search <i class="icon-search"></i></button>
<button type="button" class="cancel-button" aria-label="Cancel"><i class="icon-remove"></i></button>
</form>
</div>
......@@ -2,7 +2,7 @@
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);
}
});
......
......@@ -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);
}
});
});
......
......@@ -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');
},
......
......@@ -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);
......@@ -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;
}
});
......
RequireJS.require([
'jquery',
'backbone',
'js/search/course/search_app',
'js/search/base/routers/search_router',
'js/search/course/views/search_form',
'js/search/base/collections/search_collection',
'js/search/course/views/search_results_view'
], function ($, Backbone, SearchApp, SearchRouter, CourseSearchForm, SearchCollection, CourseSearchResultsView) {
'use strict';
var courseId = $('#courseware-search-results').data('courseId');
var app = new SearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
Backbone.history.start();
});
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (courseId, SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([], { courseId: courseId });
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#courseware-search-bar'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#course_search_item-tpl'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/course/views/search_item_view'
], function (SearchResultsView, CourseSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#courseware-search-results',
contentElement: '#course-content',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
},
SearchItemView: CourseSearchItemView
});
});
})(define || RequireJS.define);
RequireJS.require([
'backbone',
'js/search/dashboard/search_app',
'js/search/base/routers/search_router',
'js/search/dashboard/views/search_form',
'js/search/base/collections/search_collection',
'js/search/dashboard/views/search_results_view'
], function (Backbone, SearchApp, SearchRouter, DashSearchForm, SearchCollection, DashSearchResultsView) {
'use strict';
var app = new SearchApp(
SearchRouter,
DashSearchForm,
SearchCollection,
DashSearchResultsView
);
Backbone.history.start();
});
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([]);
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(results, 'reset', function () {
form.resetSearchForm();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#dashboard-search-bar'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#dashboard_search_item-tpl'
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/dashboard/views/search_item_view'
], function (SearchResultsView, DashSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#dashboard-search-results',
contentElement: '#my-courses, #profile-sidebar',
resultsTemplateId: '#dashboard_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
'click .search-back-to-courses': 'backToCourses'
},
SearchItemView: DashSearchItemView,
backToCourses: function () {
this.clear();
this.trigger('reset');
return false;
}
});
});
})(define || RequireJS.define);
RequireJS.require([
'jquery',
'backbone',
'js/search/search_app'
], function ($, Backbone, SearchApp) {
'use strict';
var course_id = $('#courseware-search-results').attr('data-course-id');
var app = new SearchApp(course_id);
Backbone.history.start();
});
;(function (define) {
define([
'backbone',
'js/search/search_router',
'js/search/views/search_form',
'js/search/views/search_list_view',
'js/search/collections/search_collection'
], function(Backbone, SearchRouter, SearchForm, SearchListView, SearchCollection) {
'use strict';
return function (course_id) {
var self = this;
this.router = new SearchRouter();
this.form = new SearchForm();
this.collection = new SearchCollection([], { course_id: course_id });
this.results = new SearchListView({ collection: this.collection });
this.form.on('search', this.results.showLoadingMessage, this.results);
this.form.on('search', this.collection.performSearch, this.collection);
this.form.on('search', function (term) {
self.router.navigate('search/' + term, { replace: true });
});
this.form.on('clear', this.collection.cancelSearch, this.collection);
this.form.on('clear', this.results.clear, this.results);
this.form.on('clear', this.router.navigate, this.router);
this.results.on('next', this.collection.loadNextPage, this.collection);
this.router.on('route:search', this.form.doSearch, this.form);
};
});
})(define || RequireJS.define);
......@@ -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:
......
......@@ -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
......@@ -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
......
.course-index .courseware-search-bar {
.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;
padding: ($baseline/4);
.search-field-wrapper {
position: relative;
}
.search-field {
@extend %t-weight2;
@include box-sizing(border-box);
top: 5px;
width: 100%;
@include border-radius(4px);
border-radius: 4px;
background: $white-t1;
&.is-active {
background: $white;
}
}
.search-button, .cancel-button {
.search-button, .cancel-button,
.search-button:hover, .cancel-button:hover {
@extend %t-icon6;
@extend %t-regular;
@include box-sizing(border-box);
color: #888;
font-size: 14px;
font-weight: normal;
@include right(12px);
display: block;
position: absolute;
right: 12px;
top: 5px;
height: 35px;
line-height: 35px;
padding: 0;
top: 0;
border: none;
box-shadow: none;
background: transparent;
padding: 0;
height: 35px;
color: $gray-l1;
box-shadow: none;
line-height: 35px;
text-shadow: none;
text-transform: none;
}
.cancel-button {
......@@ -40,49 +46,60 @@
}
.courseware-search-results {
.search-results {
display: none;
padding: 40px;
.search-info {
padding-bottom: lh(.75);
margin-bottom: lh(.50);
border-bottom: 1px solid $gray-l2;
border-bottom: 4px solid $border-color-l4;
padding-bottom: $baseline;
.search-count {
float: right;
color: $gray;
@include float(right);
color: $gray-l1;
}
}
.search-results {
.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;
margin-bottom: lh(.75);
padding-bottom: lh(.75);
padding-right: 140px;
cursor: pointer;
.sri-excerpt {
color: $gray;
margin-bottom: lh(1);
&:hover {
background: $gray-l6;
}
.sri-type {
.result-excerpt {
margin-bottom: $baseline;
}
.result-type {
@include right($baseline/2);
position: absolute;
right: 0;
top: 0;
color: $gray;
bottom: $baseline;
font-size: 14px;
color: $gray-l1;
}
.result-course-name {
@include margin-right(1em);
font-size: 14px;
color: $gray-l1;
}
.sri-link {
.result-location {
font-size: 14px;
color: $gray-l1;
}
.result-link {
@include right($baseline/2);
position: absolute;
right: 0;
top: $baseline;
line-height: 1.6em;
bottom: lh(.75);
text-transform: uppercase;
}
.search-results-ellipsis {
......@@ -97,14 +114,64 @@
.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;
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);
}
......@@ -24,9 +24,9 @@ ${page_title_breadcrumbs(course_name())}
</script>
% endfor
% for template_name in ["search_item", "search_item_seq", "search_list", "search_loading", "search_error"]:
% for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="courseware_search/${template_name}.underscore" />
<%static:include path="search/${template_name}.underscore" />
</script>
% endfor
......@@ -149,16 +149,18 @@ ${fragment.foot_html()}
</header>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="courseware-search-bar" role="search" aria-label="Course">
<div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">${_('Course Search')}</label>
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
<div class="search-field-wrapper">
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
% endif
......@@ -198,7 +200,7 @@ ${fragment.foot_html()}
${fragment.body_html()}
</section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<section class="courseware-search-results" id="courseware-search-results" data-course-id="${course.id}" data-course-name="${course.display_name_with_default}">
<section id="courseware-search-results" class="search-results courseware-search-results" data-course-id="${course.id}">
</section>
% endif
</div>
......
<div class='sri-excerpt'><%= excerpt %></div>
<span class='sri-type'><%- content_type %></span>
<span class='sri-location'><%- location.join(' ▸ ') %></span>
<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<div class='sri-excerpt'></div>
<span class='sri-type'></span>
<span class='sri-location'><%- location.join(' ▸ ') %></span>
<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
......@@ -27,6 +27,12 @@
<%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">
......@@ -69,8 +75,8 @@
<header class="wrapper-header-courses">
<h2 class="header-courses">${_("Current Courses")}</h2>
</header>
% if len(course_enrollment_pairs) > 0:
<ul class="listing-courses">
<% share_settings = settings.FEATURES.get('DASHBOARD_SHARE_SETTINGS', {}) %>
......@@ -126,7 +132,32 @@
</div>
% endif
</section>
<section class="profile-sidebar" role="region" aria-label="User info">
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<div id="dashboard-search-bar" class="search-bar dashboard-search-bar" role="search" aria-label="Dashboard">
<form>
<label for="dashboard-search-input">${_('Search Your Courses')}</label>
<div class="search-field-wrapper">
<input id="dashboard-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="${_('Search')}">
<i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
% endif
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<section id="dashboard-search-results" class="search-results dashboard-search-results"></section>
% endif
<section class="profile-sidebar" id="profile-sidebar" role="region" aria-label="User info">
<header class="profile">
<h2 class="username-header"><span class="sr">${_("Username")}: </span><span class="username-label">${ user.username }</span></h2>
</header>
<section class="user-info">
<ul>
<li class="heads-up">
......
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<span class="result-location"><%- location.join(' ▸ ') %></span>
<span class="result-type"><%- content_type %></span>
......@@ -5,10 +5,10 @@
<% if (totalCount > 0 ) { %>
<ol class='search-results'></ol>
<ol class='search-result-list'></ol>
<% if (hasMoreResults) { %>
<a class="search-load-next" href="javascript:void(0);">
<a class="search-load-next" href="#">
<%= interpolate(
ngettext("Load next %(num_items)s result", "Load next %(num_items)s results", pageSize),
{ num_items: pageSize },
......
<div class="result-excerpt"><%= excerpt %></div>
<a class="result-link" href="<%- url %>"><%= gettext("View") %> <i class="icon fa fa-arrow-right"></i></a>
<span class="result-course-name"><%- course_name %>:</span>
<span class="result-location"><%- location.join(' ▸ ') %></span>
<span class="result-type"><%- content_type %></span>
<header class="search-info">
<a class="search-back-to-courses" href="#"><%= gettext("Back to Dashboard") %></a>
<h2><%= gettext("Search Results") %></h2>
<div class="search-count"><%= totalCountMsg %></div>
</header>
<% if (totalCount > 0 ) { %>
<ol class='search-result-list'></ol>
<% if (hasMoreResults) { %>
<a class="search-load-next" href="#">
<%= interpolate(
ngettext("Load next %(num_items)s result", "Load next %(num_items)s results", pageSize),
{ num_items: pageSize },
true
) %>
<i class="icon fa fa-spinner fa-spin"></i>
</a>
<% } %>
<% } else { %>
<p><%= gettext("Sorry, no results were found.") %></p>
<% } %>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment