-
+
- <%= org %> +
- <%= content.number %> +
- <%= gettext("Starts") %> +
diff --git a/common/test/acceptance/pages/lms/courseware_search.py b/common/test/acceptance/pages/lms/courseware_search.py
index ce4fc14f52..8f4a57515a 100644
--- a/common/test/acceptance/pages/lms/courseware_search.py
+++ b/common/test/acceptance/pages/lms/courseware_search.py
@@ -33,7 +33,7 @@ class CoursewareSearchPage(CoursePage):
def search_for_term(self, text):
"""
- Search and return results
+ Fill input and do search
"""
self.enter_search_term(text)
self.search()
diff --git a/common/test/acceptance/pages/lms/discovery.py b/common/test/acceptance/pages/lms/discovery.py
new file mode 100644
index 0000000000..00547ccf9d
--- /dev/null
+++ b/common/test/acceptance/pages/lms/discovery.py
@@ -0,0 +1,47 @@
+"""
+Course discovery page.
+"""
+
+from . import BASE_URL
+from bok_choy.page_object import PageObject
+
+
+class CourseDiscoveryPage(PageObject):
+ """
+ Find courses page (main page of the LMS).
+ """
+
+ url = BASE_URL + "/courses"
+ form = "#discovery-form"
+
+ def is_browser_on_page(self):
+ return "Courses" in self.browser.title
+
+ @property
+ def result_items(self):
+ """
+ Return search result items.
+ """
+ return self.q(css=".courses-listing-item")
+
+ @property
+ def clear_button(self):
+ """
+ Clear all button.
+ """
+ return self.q(css="#clear-all-filters")
+
+ def search(self, string):
+ """
+ Search and wait for ajax.
+ """
+ self.q(css=self.form + ' input[type="text"]').fill(string)
+ self.q(css=self.form + ' [type="submit"]').click()
+ self.wait_for_ajax()
+
+ def clear_search(self):
+ """
+ Clear search results.
+ """
+ self.clear_button.click()
+ self.wait_for_ajax()
diff --git a/common/test/acceptance/tests/lms/test_lms_course_discovery.py b/common/test/acceptance/tests/lms/test_lms_course_discovery.py
new file mode 100644
index 0000000000..c2be96f32c
--- /dev/null
+++ b/common/test/acceptance/tests/lms/test_lms_course_discovery.py
@@ -0,0 +1,84 @@
+"""
+Test course discovery.
+"""
+import datetime
+import json
+import os
+
+from bok_choy.web_app_test import WebAppTest
+from ...pages.common.logout import LogoutPage
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.lms.discovery import CourseDiscoveryPage
+from ...fixtures.course import CourseFixture
+
+
+class CourseDiscoveryTest(WebAppTest):
+ """
+ Test searching for courses.
+ """
+
+ STAFF_USERNAME = "STAFF_TESTER"
+ STAFF_EMAIL = "staff101@example.com"
+ TEST_INDEX_FILENAME = "test_root/index_file.dat"
+
+ def setUp(self):
+ """
+ Create course page and courses to find
+ """
+ # create index file
+ with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
+ json.dump({}, index_file)
+
+ self.addCleanup(os.remove, self.TEST_INDEX_FILENAME)
+
+ super(CourseDiscoveryTest, self).setUp()
+ self.page = CourseDiscoveryPage(self.browser)
+
+ for i in range(10):
+ org = self.unique_id
+ number = unicode(i)
+ run = "test_run"
+ name = "test course"
+ settings = {'enrollment_start': datetime.datetime(1970, 1, 1).isoformat()}
+ CourseFixture(org, number, run, name, settings=settings).install()
+
+ for i in range(2):
+ org = self.unique_id
+ number = unicode(i)
+ run = "test_run"
+ name = "grass is always greener"
+ CourseFixture(
+ org,
+ number,
+ run,
+ name,
+ settings={
+ 'enrollment_start': datetime.datetime(1970, 1, 1).isoformat()
+ }
+ ).install()
+
+ 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 test_page_existence(self):
+ """
+ Make sure that the page is accessible.
+ """
+ self.page.visit()
+
+ def test_search(self):
+ """
+ Make sure you can search for courses.
+ """
+ self.page.visit()
+ self.assertEqual(len(self.page.result_items), 12)
+
+ self.page.search("grass")
+ self.assertEqual(len(self.page.result_items), 2)
+
+ self.page.clear_search()
+ self.assertEqual(len(self.page.result_items), 12)
diff --git a/lms/djangoapps/branding/tests/test_page.py b/lms/djangoapps/branding/tests/test_page.py
index 253733a37c..199784da06 100644
--- a/lms/djangoapps/branding/tests/test_page.py
+++ b/lms/djangoapps/branding/tests/test_page.py
@@ -200,6 +200,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
@patch('student.views.render_to_response', RENDER_MOCK)
@patch('courseware.views.render_to_response', RENDER_MOCK)
+ @patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
def test_course_cards_sorted_by_default_sorting(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
@@ -225,6 +226,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
@patch('student.views.render_to_response', RENDER_MOCK)
@patch('courseware.views.render_to_response', RENDER_MOCK)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_SORTING_BY_START_DATE': False})
+ @patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
def test_course_cards_sorted_by_start_date_disabled(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 4c0d93b7a2..2e2fab6f4c 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -116,15 +116,21 @@ def courses(request):
"""
Render "find courses" page. The course selection work is done in courseware.courses.
"""
- courses = get_courses(request.user, request.META.get('HTTP_HOST'))
+ courses_list = []
+ course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {})
+ if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'):
+ courses_list = get_courses(request.user, request.META.get('HTTP_HOST'))
- if microsite.get_value("ENABLE_COURSE_SORTING_BY_START_DATE",
- settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]):
- courses = sort_by_start_date(courses)
- else:
- courses = sort_by_announcement(courses)
+ if microsite.get_value("ENABLE_COURSE_SORTING_BY_START_DATE",
+ settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]):
+ courses_list = sort_by_start_date(courses_list)
+ else:
+ courses_list = sort_by_announcement(courses_list)
- return render_to_response("courseware/courses.html", {'courses': courses})
+ return render_to_response(
+ "courseware/courses.html",
+ {'courses': courses_list, 'course_discovery_meanings': course_discovery_meanings}
+ )
def render_accordion(request, course, chapter, section, field_data_cache):
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 3e0d69b7d0..8851f9d62b 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1303,6 +1303,8 @@ reverify_js = [
ccx_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/ccx/**/*.js'))
+discovery_js = ['js/discovery/main.js']
+
PIPELINE_CSS = {
'style-vendor': {
@@ -1504,7 +1506,11 @@ PIPELINE_JS = {
},
'footer_edx': {
'source_filenames': ['js/footer-edx.js'],
- 'output_filename': 'js/footer-edx.js',
+ 'output_filename': 'js/footer-edx.js'
+ },
+ 'discovery': {
+ 'source_filenames': discovery_js,
+ 'output_filename': 'js/discovery.js'
}
}
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index 21a0df9f3f..5b2dd0a0e3 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -133,6 +133,22 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True
########################## Course Discovery #######################
+from django.utils.translation import ugettext as _
+LANGUAGE_MAP = {'terms': {lang: display for lang, display in ALL_LANGUAGES}, 'name': _('Language')}
+COURSE_DISCOVERY_MEANINGS = {
+ 'org': {
+ 'name': _('Organization'),
+ },
+ 'modes': {
+ 'name': _('Course Type'),
+ 'terms': {
+ 'honor': _('Honor'),
+ 'verified': _('Verified'),
+ },
+ },
+ 'language': LANGUAGE_MAP,
+}
+
FEATURES['ENABLE_COURSE_DISCOVERY'] = True
FEATURES['COURSES_ARE_BROWSEABLE'] = True
HOMEPAGE_COURSE_MAX = 9
diff --git a/lms/static/js/discovery/app.js b/lms/static/js/discovery/app.js
new file mode 100644
index 0000000000..584c24169f
--- /dev/null
+++ b/lms/static/js/discovery/app.js
@@ -0,0 +1,70 @@
+;(function (define) {
+
+define(['backbone', 'course_discovery_meanings'], function(Backbone, meanings) {
+ 'use strict';
+
+ return function (Collection, Form, ResultListView, FilterBarView, FacetsBarView, searchQuery) {
+ //facet types configuration - set default display names
+ var facetsTypes = meanings;
+
+ var collection = new Collection([]);
+ var results = new ResultListView({ collection: collection });
+ var dispatcher = _.clone(Backbone.Events);
+ var form = new Form();
+ var filters = new FilterBarView();
+ var facetsBarView = new FacetsBarView(facetsTypes);
+
+ dispatcher.listenTo(form, 'search', function (query) {
+ form.showLoadingIndicator();
+ filters.changeQueryFilter(query);
+ });
+
+ dispatcher.listenTo(filters, 'search', function (searchTerm, facets) {
+ collection.performSearch(searchTerm, facets);
+ form.showLoadingIndicator();
+ });
+
+ dispatcher.listenTo(filters, 'clear', function () {
+ form.clearSearch();
+ collection.performSearch();
+ filters.hideClearAllButton();
+ });
+
+ dispatcher.listenTo(results, 'next', function () {
+ collection.loadNextPage();
+ form.showLoadingIndicator();
+ });
+
+ dispatcher.listenTo(collection, 'search', function () {
+ if (collection.length > 0) {
+ results.render();
+ }
+ else {
+ form.showNotFoundMessage(collection.searchTerm);
+ }
+ facetsBarView.renderFacets(collection.facets);
+ form.hideLoadingIndicator();
+ });
+
+ dispatcher.listenTo(collection, 'next', function () {
+ results.renderNext();
+ form.hideLoadingIndicator();
+ });
+
+ dispatcher.listenTo(collection, 'error', function () {
+ form.showErrorMessage();
+ form.hideLoadingIndicator();
+ });
+
+ dispatcher.listenTo(facetsBarView, 'addFilter', function (data) {
+ filters.addFilter(data);
+ });
+
+ // kick off search on page refresh
+ form.doSearch(searchQuery);
+
+ };
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/collection.js b/lms/static/js/discovery/collection.js
new file mode 100644
index 0000000000..52cdb0c0da
--- /dev/null
+++ b/lms/static/js/discovery/collection.js
@@ -0,0 +1,108 @@
+;(function (define) {
+
+define([
+ 'backbone',
+ 'js/discovery/result'
+], function (Backbone, Result) {
+ 'use strict';
+
+ return Backbone.Collection.extend({
+
+ model: Result,
+ pageSize: 20,
+ totalCount: 0,
+ latestModelsCount: 0,
+ searchTerm: '',
+ selectedFacets: {},
+ facets: {},
+ page: 0,
+ url: '/search/course_discovery/',
+ fetchXhr: null,
+
+ performSearch: function (searchTerm, facets) {
+ this.fetchXhr && this.fetchXhr.abort();
+ this.searchTerm = searchTerm || '';
+ this.selectedFacets = facets || {};
+ var data = this.preparePostData(0);
+ this.resetState();
+ this.fetchXhr = this.fetch({
+ data: data,
+ type: 'POST',
+ success: function (self, xhr) {
+ self.trigger('search');
+ },
+ error: function (self, xhr) {
+ self.trigger('error');
+ }
+ });
+ },
+
+ loadNextPage: function () {
+ this.fetchXhr && this.fetchXhr.abort();
+ var data = this.preparePostData(this.page + 1);
+ this.fetchXhr = this.fetch({
+ data: data,
+ type: 'POST',
+ success: function (self, xhr) {
+ self.page += 1;
+ self.trigger('next');
+ },
+ error: function (self, xhr) {
+ self.trigger('error');
+ },
+ add: true,
+ reset: false,
+ remove: false
+ });
+ },
+
+ preparePostData: function(pageNumber) {
+ var data = {
+ search_string: this.searchTerm,
+ page_size: this.pageSize,
+ page_index: pageNumber
+ };
+ if(this.selectedFacets.length > 0) {
+ this.selectedFacets.each(function(facet) {
+ data[facet.get('type')] = facet.get('query');
+ });
+ }
+ return data;
+ },
+
+ parse: function(response) {
+ var results = response['results'] || [];
+ this.latestModelsCount = results.length;
+ this.totalCount = response.total;
+ if (typeof response.facets !== 'undefined') {
+ this.facets = response.facets;
+ }
+ else {
+ this.facets = [];
+ }
+ return _.map(results, function (result) {
+ return result.data;
+ });
+ },
+
+ resetState: function () {
+ this.reset();
+ this.page = 0;
+ this.totalCount = 0;
+ this.latestModelsCount = 0;
+ },
+
+ hasNextPage: function () {
+ return this.totalCount - ((this.page + 1) * this.pageSize) > 0;
+ },
+
+ latestModels: function () {
+ return this.last(this.latestModelsCount);
+ }
+
+ });
+
+});
+
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/facet_view.js b/lms/static/js/discovery/facet_view.js
new file mode 100644
index 0000000000..2100db71b8
--- /dev/null
+++ b/lms/static/js/discovery/facet_view.js
@@ -0,0 +1,36 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+], function ($, _, Backbone, gettext) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ tagName: 'li',
+ templateId: '#search_facet-tpl',
+ className: '',
+
+ initialize: function () {
+ this.tpl = _.template($(this.templateId).html());
+ },
+
+ render: function (type, name, term, count) {
+ this.$el.html(this.tpl({name: name, term: term, count: count}));
+ this.$el.attr('data-facet', type);
+ return this;
+ },
+
+ remove: function() {
+ this.stopListening();
+ this.$el.remove();
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/facets_view.js b/lms/static/js/discovery/facets_view.js
new file mode 100644
index 0000000000..82aea6d9e7
--- /dev/null
+++ b/lms/static/js/discovery/facets_view.js
@@ -0,0 +1,46 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+], function ($, _, Backbone, gettext) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ tagName: 'section',
+ templateId: '#search_facets_section-tpl',
+ className: '',
+ total: 0,
+ terms: {},
+ other: 0,
+ list: [],
+ views: {},
+ attributes: {'data-parent-element' : 'sidebar'},
+
+ initialize: function () {
+ this.tpl = _.template($(this.templateId).html());
+ },
+
+ render: function (facetName, displayName, facetStats) {
+ this.$el.html(this.tpl({name: facetName, displayName: displayName, stats: facetStats}));
+ this.$el.attr('data-facet', facetName);
+ this.$views = this.$el.find('ul');
+ return this;
+ },
+
+ remove: function() {
+ $.each(this.list, function(key, facet) {
+ facet.remove();
+ });
+ this.stopListening();
+ this.$el.remove();
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/filter.js b/lms/static/js/discovery/filter.js
new file mode 100644
index 0000000000..063f05618c
--- /dev/null
+++ b/lms/static/js/discovery/filter.js
@@ -0,0 +1,19 @@
+;(function (define) {
+
+define(['backbone'], function (Backbone) {
+ 'use strict';
+
+ return Backbone.Model.extend({
+ defaults: {
+ query: '',
+ type: 'search_string'
+ },
+
+ cleanModelView: function() {
+ this.destroy();
+ }
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/filter_bar_view.js b/lms/static/js/discovery/filter_bar_view.js
new file mode 100644
index 0000000000..6e10df498f
--- /dev/null
+++ b/lms/static/js/discovery/filter_bar_view.js
@@ -0,0 +1,119 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+ 'js/discovery/filters',
+ 'js/discovery/filter',
+ 'js/discovery/filter_view'
+], function ($, _, Backbone, gettext, FiltersCollection, Filter, FilterView) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ el: '#filter-bar',
+
+ tagName: 'div',
+ templateId: '#filter_bar-tpl',
+ className: 'filters hidden',
+
+ events: {
+ 'click #clear-all-filters': 'clearAll',
+ 'click li .discovery-button': 'clearFilter'
+ },
+
+ initialize: function () {
+ this.collection = new FiltersCollection([]);
+ this.tpl = _.template($(this.templateId).html());
+ this.$el.html(this.tpl());
+ this.hideClearAllButton();
+ this.filtersList = this.$el.find('ul');
+ },
+
+ render: function () {
+ return this;
+ },
+
+ changeQueryFilter: function(query) {
+ var queryModel = this.collection.getQueryModel();
+ if (typeof queryModel !== 'undefined') {
+ this.collection.remove(queryModel);
+ }
+
+ if (query) {
+ var data = {query: query, type: 'search_string'};
+ this.addFilter(data);
+ }
+ else {
+ this.startSearch();
+ }
+ },
+
+ addFilter: function(data) {
+ var currentfilter = this.collection.findWhere(data);
+ if(typeof currentfilter === 'undefined') {
+ var filter = new Filter(data);
+ var filterView = new FilterView({model: filter});
+ this.collection.add(filter);
+ this.filtersList.append(filterView.render().el);
+ this.trigger('search', this.getSearchTerm(), this.collection);
+ if (this.$el.hasClass('hidden')) {
+ this.showClearAllButton();
+ }
+ }
+ },
+
+ clearFilter: function (event) {
+ event.preventDefault();
+ var $target = $(event.currentTarget);
+ var clearModel = this.collection.findWhere({
+ query: $target.data('value'),
+ type: $target.data('type')
+ });
+ this.collection.remove(clearModel);
+ this.startSearch();
+ },
+
+ clearFilters: function() {
+ this.collection.reset([]);
+ this.filtersList.empty();
+ },
+
+ clearAll: function(event) {
+ event.preventDefault();
+ this.clearFilters();
+ this.trigger('clear');
+ },
+
+ showClearAllButton: function () {
+ this.$el.removeClass('hidden');
+ },
+
+ hideClearAllButton: function() {
+ this.$el.addClass('hidden');
+ },
+
+ getSearchTerm: function() {
+ var queryModel = this.collection.getQueryModel();
+ if (typeof queryModel !== 'undefined') {
+ return queryModel.get('query');
+ }
+ return '';
+ },
+
+ startSearch: function() {
+ if (this.collection.length === 0) {
+ this.trigger('clear');
+ }
+ else {
+ this.trigger('search', this.getSearchTerm(), this.collection);
+ }
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/filter_view.js b/lms/static/js/discovery/filter_view.js
new file mode 100644
index 0000000000..7c5b6826b3
--- /dev/null
+++ b/lms/static/js/discovery/filter_view.js
@@ -0,0 +1,39 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+], function ($, _, Backbone, gettext) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ tagName: 'li',
+ templateId: '#filter-tpl',
+ className: 'active-filter',
+
+ initialize: function () {
+ this.tpl = _.template($(this.templateId).html());
+ this.listenTo(this.model, 'destroy', this.remove);
+ },
+
+ render: function () {
+ this.className = this.model.get('type');
+ var data = this.model.attributes;
+ data.name = data.name || data.query;
+ this.$el.html(this.tpl(data));
+ return this;
+ },
+
+ remove: function() {
+ this.stopListening();
+ this.$el.remove();
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/filters.js b/lms/static/js/discovery/filters.js
new file mode 100644
index 0000000000..d61fc4722e
--- /dev/null
+++ b/lms/static/js/discovery/filters.js
@@ -0,0 +1,30 @@
+;(function (define) {
+
+define([
+ 'backbone',
+ 'js/discovery/filter'
+], function (Backbone, Filter) {
+ 'use strict';
+
+ return Backbone.Collection.extend({
+
+ model: Filter,
+ url: '',
+
+ initialize: function () {
+ this.bind('remove', this.onModelRemoved, this);
+ },
+
+ onModelRemoved: function (model, collection, options) {
+ model.cleanModelView();
+ },
+
+ getQueryModel: function() {
+ return this.findWhere({'type': 'search_string'});
+ }
+ });
+
+});
+
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/form.js b/lms/static/js/discovery/form.js
new file mode 100644
index 0000000000..5764da2af9
--- /dev/null
+++ b/lms/static/js/discovery/form.js
@@ -0,0 +1,66 @@
+;(function (define) {
+
+define(['jquery', 'backbone'], function ($, Backbone) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ el: '#discovery-form',
+ events: {
+ 'submit form': 'submitForm',
+ },
+
+ initialize: function () {
+ this.$searchField = this.$el.find('input');
+ this.$searchButton = this.$el.find('button');
+ this.$message = this.$el.find('#discovery-message');
+ this.$loadingIndicator = this.$el.find('#loading-indicator');
+ },
+
+ submitForm: function (event) {
+ event.preventDefault();
+ this.doSearch();
+ },
+
+ doSearch: function (term) {
+ if (term) {
+ this.$searchField.val(term);
+ }
+ else {
+ term = this.$searchField.val();
+ }
+ this.trigger('search', $.trim(term));
+ this.$message.empty();
+ },
+
+ clearSearch: function () {
+ this.$message.empty();
+ this.$searchField.val('');
+ },
+
+ showLoadingIndicator: function () {
+ this.$message.empty();
+ this.$loadingIndicator.removeClass('hidden');
+ },
+
+ hideLoadingIndicator: function () {
+ this.$loadingIndicator.addClass('hidden');
+ },
+
+ showNotFoundMessage: function (term) {
+ var msg = interpolate(
+ gettext('We couldn\'t find any results for "%s".'),
+ [_.escape(term)]
+ );
+ this.$message.html(msg);
+ },
+
+ showErrorMessage: function () {
+ this.$message.html(gettext('There was an error, try searching again.'));
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/main.js b/lms/static/js/discovery/main.js
new file mode 100644
index 0000000000..7325c21568
--- /dev/null
+++ b/lms/static/js/discovery/main.js
@@ -0,0 +1,22 @@
+RequireJS.require([
+ 'jquery',
+ 'backbone',
+ 'js/discovery/app',
+ 'js/discovery/collection',
+ 'js/discovery/form',
+ 'js/discovery/result_list_view',
+ 'js/discovery/filter_bar_view',
+ 'js/discovery/search_facets_view'
+], function ($, Backbone, App, Collection, DiscoveryForm, ResultListView, FilterBarView, FacetsBarView) {
+ 'use strict';
+
+ var app = new App(
+ Collection,
+ DiscoveryForm,
+ ResultListView,
+ FilterBarView,
+ FacetsBarView,
+ getParameterByName('search_query')
+ );
+
+});
diff --git a/lms/static/js/discovery/result.js b/lms/static/js/discovery/result.js
new file mode 100644
index 0000000000..e00aba5934
--- /dev/null
+++ b/lms/static/js/discovery/result.js
@@ -0,0 +1,26 @@
+;(function (define) {
+
+define(['backbone'], function (Backbone) {
+ 'use strict';
+
+ return Backbone.Model.extend({
+ defaults: {
+ modes: [],
+ course: '',
+ enrollment_start: '',
+ number: '',
+ content: {
+ overview: '',
+ display_name: '',
+ number: ''
+ },
+ start: '',
+ image_url: '',
+ org: '',
+ id: ''
+ }
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/result_item_view.js b/lms/static/js/discovery/result_item_view.js
new file mode 100644
index 0000000000..c30f2f90a4
--- /dev/null
+++ b/lms/static/js/discovery/result_item_view.js
@@ -0,0 +1,50 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+ 'date'
+], function ($, _, Backbone, gettext, Date) {
+ 'use strict';
+
+ function formatDate(date) {
+ return dateUTC(date).toString('MMM dd, yyyy');
+ }
+
+ // Return a date object using UTC time instead of local time
+ function dateUTC(date) {
+ return new Date(
+ date.getUTCFullYear(),
+ date.getUTCMonth(),
+ date.getUTCDate(),
+ date.getUTCHours(),
+ date.getUTCMinutes(),
+ date.getUTCSeconds()
+ );
+ }
+
+ return Backbone.View.extend({
+
+ tagName: 'li',
+ templateId: '#result_item-tpl',
+ className: 'courses-listing-item',
+
+ initialize: function () {
+ this.tpl = _.template($(this.templateId).html());
+ },
+
+ render: function () {
+ var data = _.clone(this.model.attributes);
+ data.start = formatDate(new Date(data.start));
+ data.enrollment_start = formatDate(new Date(data.enrollment_start));
+ this.$el.html(this.tpl(data));
+ return this;
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/result_list_view.js b/lms/static/js/discovery/result_list_view.js
new file mode 100644
index 0000000000..ef0b7cc096
--- /dev/null
+++ b/lms/static/js/discovery/result_list_view.js
@@ -0,0 +1,84 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+ 'js/discovery/result_item_view'
+], function ($, _, Backbone, gettext, ResultItemView) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ el: 'section.courses',
+ $window: $(window),
+ $document: $(document),
+
+ initialize: function () {
+ this.$list = this.$el.find('.courses-listing');
+ this.attachScrollHandler();
+ },
+
+ render: function () {
+ this.$list.empty();
+ this.renderItems();
+ return this;
+ },
+
+ renderNext: function () {
+ this.renderItems();
+ this.isLoading = false;
+ },
+
+ renderItems: function () {
+ var latest = this.collection.latestModels();
+ var items = latest.map(function (result) {
+ var item = new ResultItemView({ model: result });
+ return item.render().el;
+ }, this);
+ this.$list.append(items);
+ },
+
+ attachScrollHandler: function () {
+ this.nextScrollEvent = true;
+ this.$window.on('scroll', this.scrollHandler.bind(this));
+ },
+
+ scrollHandler: function () {
+ if (this.nextScrollEvent) {
+ setTimeout(this.throttledScrollHandler.bind(this), 400);
+ this.nextScrollEvent = false;
+ }
+ },
+
+ throttledScrollHandler: function () {
+ if (this.isNearBottom()) {
+ this.scrolledToBottom();
+ }
+ this.nextScrollEvent = true;
+ },
+
+ isNearBottom: function () {
+ var scrollBottom = this.$window.scrollTop() + this.$window.height();
+ var threshold = this.$document.height() - 200;
+ return scrollBottom >= threshold;
+ },
+
+ scrolledToBottom: function () {
+ if (this.thereIsMore() && !this.isLoading) {
+ this.trigger('next');
+ this.isLoading = true;
+ }
+ },
+
+ thereIsMore: function () {
+ return this.collection.hasNextPage();
+ }
+
+ });
+
+});
+
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/discovery/search_facets_view.js b/lms/static/js/discovery/search_facets_view.js
new file mode 100644
index 0000000000..aabf22ebf1
--- /dev/null
+++ b/lms/static/js/discovery/search_facets_view.js
@@ -0,0 +1,127 @@
+;(function (define) {
+
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'gettext',
+ 'js/discovery/facets_view',
+ 'js/discovery/facet_view'
+], function ($, _, Backbone, gettext, FacetsView, FacetView) {
+ 'use strict';
+
+ return Backbone.View.extend({
+
+ el: '.search-facets',
+
+ tagName: 'div',
+ templateId: '#search_facets_list-tpl',
+ className: 'facets',
+ facetsTypes: {},
+ moreLessLinksTpl: '#more_less_links-tpl',
+
+ events: {
+ 'click li': 'addFacet',
+ 'click .show-less': 'collapse',
+ 'click .show-more': 'expand',
+ },
+
+ initialize: function (facetsTypes) {
+ if(facetsTypes) {
+ this.facetsTypes = facetsTypes;
+ }
+ this.tpl = _.template($(this.templateId).html());
+ this.moreLessTpl = _.template($(this.moreLessLinksTpl).html());
+ this.$el.html(this.tpl());
+ this.facetViews = [];
+ this.$facetViewsEl = this.$el.find('.search-facets-lists');
+ },
+
+ render: function () {
+ return this;
+ },
+
+ collapse: function(event) {
+ var $el = $(event.currentTarget),
+ $more = $el.siblings('.show-more'),
+ $ul = $el.parent('div').siblings('ul');
+
+ event.preventDefault();
+
+ $ul.addClass('collapse');
+ $el.addClass('hidden');
+ $more.removeClass('hidden');
+ },
+
+ expand: function(event) {
+ var $el = $(event.currentTarget),
+ $ul = $el.parent('div').siblings('ul'),
+ facets = $ul.find('li').length,
+ itemHeight = 34;
+
+ event.preventDefault();
+
+ $el.addClass('hidden');
+ $ul.removeClass('collapse');
+ $el.siblings('.show-less').removeClass('hidden');
+ },
+
+ addFacet: function(event) {
+ event.preventDefault();
+ var $target = $(event.currentTarget);
+ var value = $target.find('.facet-option').data('value');
+ var name = $target.find('.facet-option').data('text');
+ var data = {type: $target.data('facet'), query: value, name: name};
+ this.trigger('addFilter', data);
+ },
+
+ displayName: function(name, term){
+ if(this.facetsTypes.hasOwnProperty(name)) {
+ if(term) {
+ if (typeof this.facetsTypes[name].terms !== 'undefined') {
+ return this.facetsTypes[name].terms.hasOwnProperty(term) ? this.facetsTypes[name].terms[term] : term;
+ }
+ else {
+ return term;
+ }
+ }
+ else if(this.facetsTypes[name].hasOwnProperty('name')) {
+ return this.facetsTypes[name]['name'];
+ }
+ else {
+ return name;
+ }
+ }
+ else{
+ return term ? term : name;
+ }
+ },
+
+ renderFacets: function(facets) {
+ var self = this;
+ // Remove old facets
+ $.each(this.facetViews, function(key, facetsList) {
+ facetsList.remove();
+ });
+ self.facetViews = [];
+ // Render new facets
+ $.each(facets, function(name, stats) {
+ var facetsView = new FacetsView();
+ self.facetViews.push(facetsView);
+ self.$facetViewsEl.append(facetsView.render(name, self.displayName(name), stats).el);
+ $.each(stats.terms, function(term, count) {
+ var facetView = new FacetView();
+ facetsView.$views.append(facetView.render(name, self.displayName(name, term), term, count).el);
+ facetsView.list.push(facetView);
+ });
+ if(_.size(stats.terms) > 9) {
+ facetsView.$el.append(self.moreLessTpl());
+ }
+ });
+ }
+
+ });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/fixtures/discovery.html b/lms/static/js/fixtures/discovery.html
new file mode 100644
index 0000000000..72c676a1aa
--- /dev/null
+++ b/lms/static/js/fixtures/discovery.html
@@ -0,0 +1,26 @@
+
+
- %for course in courses:
+ %for course in courses: