From 7f633fc0454794cb446bdecea36b787fa4caa945 Mon Sep 17 00:00:00 2001 From: Martyn James Date: Fri, 3 Apr 2015 16:47:31 -0400 Subject: [PATCH] Course Discovery feature using edx-search --- .../acceptance/pages/lms/courseware_search.py | 2 +- common/test/acceptance/pages/lms/discovery.py | 47 ++ .../tests/lms/test_lms_course_discovery.py | 84 +++ lms/djangoapps/branding/tests/test_page.py | 2 + lms/djangoapps/courseware/views.py | 20 +- lms/envs/common.py | 8 +- lms/envs/devstack.py | 16 + lms/static/js/discovery/app.js | 70 +++ lms/static/js/discovery/collection.js | 108 ++++ lms/static/js/discovery/facet_view.js | 36 ++ lms/static/js/discovery/facets_view.js | 46 ++ lms/static/js/discovery/filter.js | 19 + lms/static/js/discovery/filter_bar_view.js | 119 ++++ lms/static/js/discovery/filter_view.js | 39 ++ lms/static/js/discovery/filters.js | 30 + lms/static/js/discovery/form.js | 66 ++ lms/static/js/discovery/main.js | 22 + lms/static/js/discovery/result.js | 26 + lms/static/js/discovery/result_item_view.js | 50 ++ lms/static/js/discovery/result_list_view.js | 84 +++ lms/static/js/discovery/search_facets_view.js | 127 ++++ lms/static/js/fixtures/discovery.html | 26 + .../discovery/course_discovery_meanings.js | 19 + .../js/spec/discovery/discovery_spec.js | 595 ++++++++++++++++++ lms/static/js/spec/main.js | 7 +- lms/static/js_test.yml | 1 + lms/static/sass/base/_variables.scss | 6 + lms/static/sass/multicourse/_courses.scss | 323 ++++++++++ lms/static/sass/views/_homepage.scss | 46 +- lms/templates/courseware/courses.html | 47 +- lms/templates/discovery/filter.underscore | 4 + lms/templates/discovery/filter_bar.underscore | 5 + .../discovery/more_less_links.underscore | 8 + .../discovery/result_item.underscore | 25 + .../discovery/search_facet.underscore | 6 + .../discovery/search_facets_list.underscore | 5 + .../search_facets_section.underscore | 5 + lms/templates/index.html | 1 + requirements/edx/github.txt | 2 +- 39 files changed, 2117 insertions(+), 35 deletions(-) create mode 100644 common/test/acceptance/pages/lms/discovery.py create mode 100644 common/test/acceptance/tests/lms/test_lms_course_discovery.py create mode 100644 lms/static/js/discovery/app.js create mode 100644 lms/static/js/discovery/collection.js create mode 100644 lms/static/js/discovery/facet_view.js create mode 100644 lms/static/js/discovery/facets_view.js create mode 100644 lms/static/js/discovery/filter.js create mode 100644 lms/static/js/discovery/filter_bar_view.js create mode 100644 lms/static/js/discovery/filter_view.js create mode 100644 lms/static/js/discovery/filters.js create mode 100644 lms/static/js/discovery/form.js create mode 100644 lms/static/js/discovery/main.js create mode 100644 lms/static/js/discovery/result.js create mode 100644 lms/static/js/discovery/result_item_view.js create mode 100644 lms/static/js/discovery/result_list_view.js create mode 100644 lms/static/js/discovery/search_facets_view.js create mode 100644 lms/static/js/fixtures/discovery.html create mode 100644 lms/static/js/spec/discovery/course_discovery_meanings.js create mode 100644 lms/static/js/spec/discovery/discovery_spec.js create mode 100644 lms/templates/discovery/filter.underscore create mode 100644 lms/templates/discovery/filter_bar.underscore create mode 100644 lms/templates/discovery/more_less_links.underscore create mode 100644 lms/templates/discovery/result_item.underscore create mode 100644 lms/templates/discovery/search_facet.underscore create mode 100644 lms/templates/discovery/search_facets_list.underscore create mode 100644 lms/templates/discovery/search_facets_section.underscore 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 @@ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
    +
    + + +
    diff --git a/lms/static/js/spec/discovery/course_discovery_meanings.js b/lms/static/js/spec/discovery/course_discovery_meanings.js new file mode 100644 index 0000000000..e468d553a7 --- /dev/null +++ b/lms/static/js/spec/discovery/course_discovery_meanings.js @@ -0,0 +1,19 @@ +define({ + org: { + name: 'Organization', + terms: { + edX1: "edX_1" + } + }, + modes: { + name: 'Course Type', + terms: { + honor: 'Honor', + verified: 'Verified' + } + }, + language: { + en: 'English', + hr: 'Croatian' + } +}); diff --git a/lms/static/js/spec/discovery/discovery_spec.js b/lms/static/js/spec/discovery/discovery_spec.js new file mode 100644 index 0000000000..6d729f10b9 --- /dev/null +++ b/lms/static/js/spec/discovery/discovery_spec.js @@ -0,0 +1,595 @@ +define([ + 'jquery', + 'backbone', + 'logger', + 'js/common_helpers/ajax_helpers', + 'js/common_helpers/template_helpers', + 'js/discovery/app', + 'js/discovery/collection', + 'js/discovery/form', + 'js/discovery/result', + 'js/discovery/result_item_view', + 'js/discovery/result_list_view', + 'js/discovery/filter', + 'js/discovery/filters', + 'js/discovery/filter_bar_view', + 'js/discovery/filter_view', + 'js/discovery/search_facets_view', + 'js/discovery/facet_view', + 'js/discovery/facets_view' +], function( + $, + Backbone, + Logger, + AjaxHelpers, + TemplateHelpers, + App, + Collection, + DiscoveryForm, + ResultItem, + ResultItemView, + ResultListView, + FilterModel, + FiltersCollection, + FiltersBarView, + FilterView, + SearchFacetView, + FacetView, + FacetsView +) { + 'use strict'; + + var JSON_RESPONSE = { + "total": 365, + "results": [ + { + "data": { + "modes": [ + "honor" + ], + "course": "edX/DemoX/Demo_Course", + "enrollment_start": "2015-04-21T00:00:00+00:00", + "number": "DemoX", + "content": { + "overview": " About This Course Include your long course description here.", + "display_name": "edX Demonstration Course", + "number": "DemoX" + }, + "start": "1970-01-01T05:00:00+00:00", + "image_url": "/c4x/edX/DemoX/asset/images_course_image.jpg", + "org": "edX", + "id": "edX/DemoX/Demo_Course" + } + } + ], + "facets": { + "org": { + "total": 26, + "terms": { + "edX1": 1, + "edX2": 1, + "edX3": 1, + "edX4": 1, + "edX5": 1, + "edX6": 1, + "edX7": 1, + "edX8": 1, + "edX9": 1, + "edX10": 1, + "edX11": 1, + "edX12": 1, + "edX13": 1, + "edX14": 1, + "edX15": 1, + "edX16": 1, + "edX17": 1, + "edX18": 1, + "edX19": 1, + "edX20": 1, + "edX21": 1, + "edX22": 1, + "edX23": 1, + "edX24": 1, + "edX25": 1, + "edX26": 1 + }, + "other": 0 + }, + "modes": { + "total": 1, + "terms": { + "honor": 1 + }, + "other": 0 + } + } + }; + + var FACET_LIST = [ + {"type": "example1", "query": "search1"}, + {"type": "example2", "query": "search2"} + ]; + + var SEARCH_FILTER = {"type": "search_string", "query": "search3"}; + + + describe('Collection', function () { + + beforeEach(function () { + this.collection = new Collection(); + + this.onSearch = jasmine.createSpy('onSearch'); + this.collection.on('search', this.onSearch); + + this.onNext = jasmine.createSpy('onNext'); + this.collection.on('next', this.onNext); + + this.onError = jasmine.createSpy('onError'); + this.collection.on('error', this.onError); + }); + + it('sends a request and parses the json result', function () { + var requests = AjaxHelpers.requests(this); + this.collection.performSearch('search string'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect(this.onSearch).toHaveBeenCalled(); + expect(this.collection.totalCount).toEqual(365); + expect(this.collection.latestModels()[0].attributes).toEqual(JSON_RESPONSE.results[0].data); + expect(this.collection.page).toEqual(0); + }); + + it('handles errors', function () { + var requests = AjaxHelpers.requests(this); + this.collection.performSearch('search string'); + AjaxHelpers.respondWithError(requests); + expect(this.onSearch).not.toHaveBeenCalled(); + expect(this.onError).toHaveBeenCalled(); + this.collection.loadNextPage(); + AjaxHelpers.respondWithError(requests); + expect(this.onSearch).not.toHaveBeenCalled(); + expect(this.onError).toHaveBeenCalled(); + }); + + it('loads next page', function () { + var requests = AjaxHelpers.requests(this); + var response = { total: 35, results: [] }; + this.collection.loadNextPage(); + AjaxHelpers.respondWithJson(requests, response); + expect(this.onNext).toHaveBeenCalled(); + expect(this.onError).not.toHaveBeenCalled(); + }); + + it('sends correct paging parameters', function () { + var requests = AjaxHelpers.requests(this); + var response = { total: 52, results: [] }; + this.collection.performSearch('search string'); + AjaxHelpers.respondWithJson(requests, response); + this.collection.loadNextPage(); + AjaxHelpers.respondWithJson(requests, response); + spyOn($, 'ajax'); + this.collection.loadNextPage(); + expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url); + expect($.ajax.mostRecentCall.args[0].data).toEqual({ + search_string : 'search string', + page_size : this.collection.pageSize, + page_index : 2 + }); + }); + + it('has next page', function () { + var requests = AjaxHelpers.requests(this); + var response = { total: 35, access_denied_count: 5, results: [] }; + this.collection.performSearch('search string'); + AjaxHelpers.respondWithJson(requests, response); + expect(this.collection.hasNextPage()).toEqual(true); + this.collection.loadNextPage(); + AjaxHelpers.respondWithJson(requests, response); + expect(this.collection.hasNextPage()).toEqual(false); + }); + + it('resets state when performing new search', function () { + this.collection.add(new ResultItem()); + expect(this.collection.length).toEqual(1); + this.collection.performSearch('search string'); + expect(this.collection.length).toEqual(0); + expect(this.collection.page).toEqual(0); + expect(this.collection.totalCount).toEqual(0); + expect(this.collection.latestModelsCount).toEqual(0); + }); + + }); + + + describe('ResultItem', function () { + + beforeEach(function () { + this.result = new ResultItem(); + }); + + it('has properties', function () { + expect(this.result.get('modes')).toBeDefined(); + expect(this.result.get('course')).toBeDefined(); + expect(this.result.get('enrollment_start')).toBeDefined(); + expect(this.result.get('number')).toBeDefined(); + expect(this.result.get('content')).toEqual({ + display_name: '', + number: '', + overview: '' + }); + expect(this.result.get('start')).toBeDefined(); + expect(this.result.get('image_url')).toBeDefined(); + expect(this.result.get('org')).toBeDefined(); + expect(this.result.get('id')).toBeDefined(); + }); + + }); + + + describe('ResultItemView', function () { + + beforeEach(function () { + TemplateHelpers.installTemplate('templates/discovery/result_item'); + this.item = new ResultItemView({ + model: new ResultItem(JSON_RESPONSE.results[0].data) + }); + }); + + it('renders correctly', function () { + var data = this.item.model.attributes; + this.item.render(); + expect(this.item.$el).toContainHtml(data.content.display_name); + expect(this.item.$el).toContain('a[href="/courses/' + data.course + '/info"]'); + expect(this.item.$el).toContain('img[src="' + data.image_url + '"]'); + expect(this.item.$el.find('.course-name')).toContainHtml(data.org); + expect(this.item.$el.find('.course-name')).toContainHtml(data.content.number); + expect(this.item.$el.find('.course-name')).toContainHtml(data.content.display_name); + expect(this.item.$el.find('.course-date')).toContainHtml('Jan 01, 1970'); + }); + + }); + + + describe('DiscoveryForm', function () { + + beforeEach(function () { + loadFixtures('js/fixtures/discovery.html'); + this.form = new DiscoveryForm(); + this.onSearch = jasmine.createSpy('onSearch'); + this.form.on('search', this.onSearch); + }); + + it('trims input string', function () { + var term = ' search string '; + $('.discovery-input').val(term); + $('form').trigger('submit'); + expect(this.onSearch).toHaveBeenCalledWith($.trim(term)); + }); + + it('handles calls to doSearch', function () { + var term = ' search string '; + $('.discovery-input').val(term); + this.form.doSearch(term); + expect(this.onSearch).toHaveBeenCalledWith($.trim(term)); + expect($('.discovery-input').val()).toEqual(term); + expect($('#discovery-message')).toBeEmpty(); + }); + + it('clears search', function () { + $('.discovery-input').val('somethig'); + this.form.clearSearch(); + expect($('.discovery-input').val()).toEqual(''); + }); + + it('shows/hides loading indicator', function () { + this.form.showLoadingIndicator(); + expect($('#loading-indicator')).not.toHaveClass('hidden'); + this.form.hideLoadingIndicator(); + expect($('#loading-indicator')).toHaveClass('hidden'); + }); + + it('shows messages', function () { + this.form.showNotFoundMessage(); + expect($('#discovery-message')).not.toBeEmpty(); + this.form.showErrorMessage(); + expect($('#discovery-message')).not.toBeEmpty(); + }); + + }); + + describe('FilterBarView', function () { + beforeEach(function () { + loadFixtures('js/fixtures/discovery.html'); + TemplateHelpers.installTemplates( + ['templates/discovery/filter_bar', + 'templates/discovery/filter'] + ); + this.filterBar = new FiltersBarView(); + this.onClear = jasmine.createSpy('onClear'); + this.filterBar.on('clear', this.onClear); + }); + + it('view searches for sent facet object', function () { + expect(this.filterBar.$el.length).toBe(1); + this.filterBar.addFilter(FACET_LIST[0]); + expect(this.filterBar.$el.find('#clear-all-filters')).toBeVisible(); + }); + + it('view searches for entered search string', function () { + spyOn(this.filterBar, 'addFilter').andCallThrough(); + expect(this.filterBar.$el.length).toBe(1); + this.filterBar.changeQueryFilter(SEARCH_FILTER.query); + expect(this.filterBar.$el.find('#clear-all-filters')).toBeVisible(); + expect(this.filterBar.addFilter).toHaveBeenCalledWith(SEARCH_FILTER); + }); + + it('model cleans view on destruction correctly', function () { + this.filterBar.addFilter(SEARCH_FILTER); + var model = this.filterBar.collection.findWhere(SEARCH_FILTER); + expect(this.filterBar.$el.find('.active-filter').length).toBe(1); + model.cleanModelView(); + expect(this.filterBar.$el.find('.active-filter').length).toBe(0); + }); + + it('view removes all filters and hides bar if clear all', function () { + spyOn(this.filterBar, 'clearAll').andCallThrough(); + this.filterBar.delegateEvents(); + this.filterBar.addFilter(SEARCH_FILTER); + var clearAll = this.filterBar.$el.find('#clear-all-filters'); + expect(clearAll).toBeVisible(); + clearAll.trigger('click'); + expect(this.filterBar.clearAll).toHaveBeenCalled(); + expect(this.onClear).toHaveBeenCalled(); + }); + + it('view hides bar if all filters removed', function () { + spyOn(this.filterBar, 'clearFilter').andCallThrough(); + this.filterBar.delegateEvents(); + this.filterBar.addFilter(SEARCH_FILTER); + var clearAll = this.filterBar.$el.find('#clear-all-filters'); + expect(clearAll).toBeVisible(); + var filter = this.filterBar.$el.find('li .discovery-button'); + filter.trigger('click'); + expect(this.filterBar.clearFilter).toHaveBeenCalled(); + expect(this.onClear).toHaveBeenCalled(); + }); + + it('view changes query filter', function () { + this.filterBar.addFilter(SEARCH_FILTER); + var filter = $(this.filterBar.$el.find('li .discovery-button')[0]); + expect(filter.text().trim()).toBe(SEARCH_FILTER.query); + // Have to explicitly remove model because events not dispatched + var model = this.filterBar.collection.findWhere(SEARCH_FILTER); + model.cleanModelView(); + this.filterBar.changeQueryFilter(SEARCH_FILTER.query + '2'); + filter = $(this.filterBar.$el.find('li .discovery-button')[0]); + expect(filter.text().trim()).toBe(SEARCH_FILTER.query + '2'); + }); + + it('view returns correct search term', function () { + this.filterBar.addFilter(SEARCH_FILTER); + expect(this.filterBar.getSearchTerm()).toBe(SEARCH_FILTER.query); + }); + + }); + + describe('SearchFacetView', function () { + beforeEach(function () { + loadFixtures('js/fixtures/discovery.html'); + TemplateHelpers.installTemplates([ + 'templates/discovery/search_facet', + 'templates/discovery/search_facets_section', + 'templates/discovery/search_facets_list', + 'templates/discovery/more_less_links' + ]); + var facetsTypes = {org: 'Organization', modes: 'Course Type'}; + this.searchFacetView = new SearchFacetView(facetsTypes); + this.searchFacetView.renderFacets(JSON_RESPONSE.facets); + this.onAddFilter = jasmine.createSpy('onAddFilter'); + this.searchFacetView.on('addFilter', this.onAddFilter); + }); + + it('view expands more content on show more click', function () { + var $showMore = this.searchFacetView.$el.find('.show-more'); + var $showLess = this.searchFacetView.$el.find('.show-less'); + var $ul = $showMore.parent('div').siblings('ul'); + expect($showMore).not.toHaveClass('hidden'); + expect($showLess).toHaveClass('hidden'); + expect($ul).toHaveClass('collapse'); + $showMore.trigger('click'); + expect($showMore).toHaveClass('hidden'); + expect($showLess).not.toHaveClass('hidden'); + expect($ul).not.toHaveClass('collapse'); + }); + + it('view collapses content on show less click', function () { + var $showMore = this.searchFacetView.$el.find('.show-more'); + var $showLess = this.searchFacetView.$el.find('.show-less'); + var $ul = $showMore.parent('div').siblings('ul'); + $showMore.trigger('click'); + expect($showMore).toHaveClass('hidden'); + expect($showLess).not.toHaveClass('hidden'); + expect($ul).not.toHaveClass('collapse'); + $showLess.trigger('click'); + expect($showMore).not.toHaveClass('hidden'); + expect($showLess).toHaveClass('hidden'); + expect($ul).toHaveClass('collapse'); + }); + + it('view triggers addFilter event if facet is clicked', function () { + this.searchFacetView.delegateEvents(); + var $facetLink = this.searchFacetView.$el.find('li [data-value="edX1"]'); + var $facet = $facetLink.parent('li'); + $facet.trigger('click'); + expect(this.onAddFilter).toHaveBeenCalledWith( + { + type: $facet.data('facet'), + query: $facetLink.data('value'), + name : $facetLink.data('text') + } + ); + }); + + it('re-render facets on second click', function () { + // First search + this.searchFacetView.delegateEvents(); + this.searchFacetView.renderFacets(JSON_RESPONSE.facets); + expect(this.searchFacetView.facetViews.length).toBe(2); + // Setup spy + var customView = this.searchFacetView.facetViews[0]; + spyOn(customView, 'remove').andCallThrough(); + // Second search + this.searchFacetView.renderFacets(JSON_RESPONSE.facets); + expect(this.searchFacetView.facetViews.length).toBe(2); + expect(customView.remove).toHaveBeenCalled(); + }); + + }); + + describe('ResultListView', function () { + + beforeEach(function () { + jasmine.Clock.useMock(); + loadFixtures('js/fixtures/discovery.html'); + TemplateHelpers.installTemplate('templates/discovery/result_item'); + var collection = new Collection([JSON_RESPONSE.results[0].data]); + collection.latestModelsCount = 1; + this.view = new ResultListView({ collection: collection }); + }); + + it('renders search results', function () { + this.view.render(); + expect($('.courses-listing article').length).toEqual(1); + expect($('.courses-listing .course-title')).toContainHtml('edX Demonstration Course'); + this.view.renderNext(); + expect($('.courses-listing article').length).toEqual(2); + }); + + it('scrolling triggers an event for next page', function () { + this.onNext = jasmine.createSpy('onNext'); + this.view.on('next', this.onNext); + spyOn(this.view.collection, 'hasNextPage').andCallFake(function () { + return true; + }); + this.view.render(); + window.scroll(0, $(document).height()); + $(window).trigger('scroll'); + jasmine.Clock.tick(500); + expect(this.onNext).toHaveBeenCalled(); + + // should not be triggered again (while it is loading) + $(window).trigger('scroll'); + jasmine.Clock.tick(500); + expect(this.onNext.calls.length).toEqual(1); + }); + + }); + + + describe('Discovery App', function () { + + beforeEach(function () { + loadFixtures('js/fixtures/discovery.html'); + TemplateHelpers.installTemplates([ + 'templates/discovery/result_item', + 'templates/discovery/filter', + 'templates/discovery/filter_bar', + 'templates/discovery/search_facet', + 'templates/discovery/search_facets_section', + 'templates/discovery/search_facets_list', + 'templates/discovery/more_less_links' + ]); + + this.app = new App( + Collection, + DiscoveryForm, + ResultListView, + FiltersBarView, + SearchFacetView + ); + }); + + it('performs search', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('test'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.courses-listing article').length).toEqual(1); + expect($('.courses-listing .course-title')).toContainHtml('edX Demonstration Course'); + expect($('.active-filter').length).toBe(1); + }); + + it('loads more', function () { + var requests = AjaxHelpers.requests(this); + jasmine.Clock.useMock(); + $('.discovery-input').val('test'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.courses-listing article').length).toEqual(1); + expect($('.courses-listing .course-title')).toContainHtml('edX Demonstration Course'); + window.scroll(0, $(document).height()); + $(window).trigger('scroll'); + jasmine.Clock.tick(500); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.courses-listing article').length).toEqual(2); + }); + + it('displays not found message', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('asdfasdf'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, {}); + expect($('#discovery-message')).not.toBeEmpty(); + expect($('.courses-listing')).toBeEmpty(); + }); + + it('displays error message', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('asdfasdf'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithError(requests, 404); + expect($('#discovery-message')).not.toBeEmpty(); + expect($('.courses-listing')).toBeEmpty(); + }); + + it('check filters and bar removed on clear all', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('test'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.active-filter').length).toBe(1); + expect($('#filter-bar')).not.toHaveClass('hidden'); + $('#clear-all-filters').trigger('click'); + expect($('.active-filter').length).toBe(0); + expect($('#filter-bar')).toHaveClass('hidden'); + }); + + it('check filters and bar removed on last filter cleared', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('test'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.active-filter').length).toBe(1); + var $filter = $('.active-filter'); + $filter.find('.discovery-button').trigger('click'); + expect($('.active-filter').length).toBe(0); + }); + + it('filter results by named facet', function () { + var requests = AjaxHelpers.requests(this); + $('.discovery-input').val('test'); + $('.discovery-submit').trigger('click'); + AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); + expect($('.active-filter').length).toBe(1); + var $facetLink = $('.search-facets li [data-value="edX1"]'); + var $facet = $facetLink.parent('li'); + $facet.trigger('click'); + expect($('.active-filter').length).toBe(2); + expect($('.active-filter [data-value="edX1"]').length).toBe(1); + expect($('.active-filter [data-value="edX1"]').text().trim()).toBe("edX_1"); + }); + + }); + + + +}); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 231effc1af..7d72799ce0 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -95,7 +95,9 @@ 'js/student_profile/views/learner_profile_view': 'js/student_profile/views/learner_profile_view', // edxnotes - 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' + 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min', + + 'course_discovery_meanings': 'js/spec/discovery/course_discovery_meanings' }, shim: { 'gettext': { @@ -626,7 +628,8 @@ 'lms/include/js/spec/edxnotes/plugins/scroller_spec.js', 'lms/include/js/spec/edxnotes/plugins/caret_navigation_spec.js', 'lms/include/js/spec/edxnotes/collections/notes_spec.js', - 'lms/include/js/spec/search/search_spec.js' + 'lms/include/js/spec/search/search_spec.js', + 'lms/include/js/spec/discovery/discovery_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 3ac6aa5f21..466c107411 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -91,6 +91,7 @@ fixture_paths: - js/fixtures/edxnotes - js/fixtures/search - templates/search + - templates/discovery requirejs: paths: diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 6928590944..61c8072e38 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -416,6 +416,12 @@ $homepage__header--gradient__color--alpha: lighten($gray, 15%); $homepage__header--gradient__color--bravo: saturate($gray, 30%); $homepage__header--background: lighten($gray, 15%); +// VIEWS: homepage and courses +$course-card-height: ($baseline*18); +$course-image-height: ($baseline*8); +$course-info-height: ($baseline*10); +$course-title-height: ($baseline*3.6); + // ==================== // IMAGES: backgrounds diff --git a/lms/static/sass/multicourse/_courses.scss b/lms/static/sass/multicourse/_courses.scss index 46b7f90f23..a3b4a97c9b 100644 --- a/lms/static/sass/multicourse/_courses.scss +++ b/lms/static/sass/multicourse/_courses.scss @@ -1,7 +1,83 @@ +@import '../base/grid-settings'; +@import 'neat/neat'; // lib - Neat + +$facet-text-color: #3d3e3f; +$facet-background-color: #007db8; + .find-courses, .university-profile { background: $course-profile-bg; padding-bottom: ($baseline*3); + .discovery-button:not(:disabled) { + @extend %t-action2; + outline: 0 none; + box-shadow:none; + border: 0; + background: none; + padding: 0 ($baseline*0.6); + text-align: left; + text-decoration: none; + text-shadow: none; + text-transform: none; + + //STATE: hover + &::hover { + background: none; + } + } + + .courses-container { + + #discovery-form { + * { + display:inline; + } + + #discovery-message, + #loading-indicator { + @include line-height(37.84); + } + } + + .courses { + @include rtl() { $layout-direction: "RTL"; } + @include span-columns(9); + + @include media($bp-medium) { + @include span-columns(4); + } + + @include media($bp-large) { + @include span-columns(8); + } + + @include media($bp-huge) { + @include span-columns(9); + } + + .courses-listing .courses-listing-item { + @include fill-parent(); + margin: ($baseline*0.75) 0 ($baseline*1.5) 0; + max-height: $course-card-height; + + @include media($bp-medium) { + @include span-columns(8); // 4 of 8 + @include omega(1n); + } + + @include media($bp-large) { + @include span-columns(6); // 6 of 12 + @include omega(2n); + } + + @include media($bp-huge) { + @include span-columns(4); // 4 of 12 + @include omega(3n); + } + } + } + } + header.search { background: $course-profile-bg; background-size: cover; @@ -90,4 +166,251 @@ padding-top: ($baseline*3); @include columns(2 20px); } + + .discovery-input { + @extend %ui-depth1; + @extend %t-icon4; + @extend %t-demi-strong; + @include border-radius(0); + @include border-top-left-radius(3px); + @include border-bottom-left-radius(3px); + border: 2px solid $gray-l3; + height: $course-search-input-height; + color: $black; + font-style: normal; + + //STATE: focus + &:focus { + @extend %no-outline; + box-shadow: none; + border-color: $m-blue-d1; + } + } + + .discovery-submit { + @extend %ui-depth2; + @extend %t-icon3; + @extend %t-strong; + @include margin-left(-2px); + position: relative; + border: 2px solid $m-blue-d1; + border-radius: ($baseline*0.1); + box-shadow: none; + background: $m-blue-d5; + padding: 0 ($baseline*0.7); + height: $course-search-input-height; + color: $white; + text-shadow: none; + + //STATE: hover, focus + &:hover, &:focus { + background: $m-blue-l1; + } + } + + .filters { + @include clearfix(); + margin-top: ($baseline/2); + border-top: 1px solid $courseware-button-border-color; + border-bottom: 1px solid $courseware-button-border-color; + width: 100%; + height: auto; + max-height: ($baseline*10); + + ul { + @include padding-left(0); + margin: 0; + list-style: outside none none; + } + + li { + @include float(left); + @include margin(($baseline/2), $baseline, ($baseline/2), 0); + position: relative; + padding: ($baseline/2) ($baseline*0.75); + background: $courseware-button-border-color; + width: auto; + + .facet-option { + @extend %t-strong; + color: $gray-d2; + text-decoration: none; + + i { + color: $gray-l2; + } + } + } + + .clear-filters { + @include line-height(29.73); + @extend %t-icon5; + @extend %t-strong; + margin: ($baseline/2) 0; + width: auto; + text-align: center; + color: $m-blue-d1; + } + + .flt-right { + @include float(right); + } + } + + + + .search-facets{ + @include fill-parent(); + @include omega(); + @include box-sizing(border-box); + @extend %ui-depth1; + position: relative; + margin: ($baseline*2) 0 ($baseline*3.5) 0; + box-shadow: 1px 2px 5px $black-t0; + border-top: 1px solid $black; + border-bottom: 2px solid $black; + background-color: $white; + max-height: ($baseline*100); + + @include media($bp-tiny) { + @include span-columns(4); + } + + @include media($bp-small) { + @include span-columns(3); + } + + @include media($bp-medium) { + @include span-columns(4); + } + + @include media($bp-large) { + @include span-columns(4); + } + + @include media($bp-huge) { + @include span-columns(3); + } + + &.phone-menu { + border: medium none; + padding: 0; + overflow: visible; + } + + &:before { + @include right(0); + position: absolute; + top: (-$baseline*0.15); + opacity: 0; + background-color: $white; + padding: ($baseline*2) ($baseline*0.75) 0 ($baseline*0.75); + width: ($baseline*2.5); + height: ($baseline/4); + content: ""; + } + + h2, + section { + @extend %t-icon5; + @extend %t-strong; + margin: 0 ($baseline/2); + border: medium none; + padding: ($baseline/2); + color: $facet-text-color; + font-family: $sans-serif; + text-transform: none; + } + + h3 { + @extend %t-icon6; + @extend %t-strong; + margin: 0 ($baseline/2) ($baseline/2) ($baseline/2); + color: $facet-text-color; + font-family: $sans-serif; + } + + section { + margin: 0; + padding: ($baseline/2) 0; + } + + .facet-option { + @include float(left); + @include box-sizing(border-box); + @include line-height(18.92); + @include transition(all $tmg-f2 ease-out 0s); + @extend %t-action3; + opacity: 1; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: $facet-text-color; + + //STATE: hover, visited + &:hover, + &:visited { + color: $facet-text-color; + text-decoration: none; + } + } + + ul { + margin: 0; + padding: 0; + overflow: hidden; + list-style: outside none none; + + &.collapse { + max-height: ($baseline*14); + } + + li { + @include clearfix(); + @include line-height(18.92); + @extend %t-icon6; + position: relative; + clear: both; + border-top: 1px solid $white; + padding: 0; + height: ($baseline*1.5); + overflow: hidden; + + .count { + @include right($baseline*0.6); + @include box-sizing(border-box); + @include transition(all 0.2s ease-out); + @include line-height(18.92); + position: absolute; + } + + //STATE: hover + &:hover { + background: $facet-background-color; + color: $white; + text-decoration: none; + + .count, + .facet-option { + color: $white; + } + } + } + } + + .search-facets-lists section { + border-top: 1px solid $courseware-button-border-color; + } + + .toggle { + @include clearfix(); + + button { + @extend %t-icon6; + @extend %t-strong; + color: $facet-background-color; + } + } + } } diff --git a/lms/static/sass/views/_homepage.scss b/lms/static/sass/views/_homepage.scss index f8f6583a69..7cba577023 100644 --- a/lms/static/sass/views/_homepage.scss +++ b/lms/static/sass/views/_homepage.scss @@ -1,10 +1,6 @@ // lms - views - homepage view // ==================== -$course-card-height: ($baseline*18); -$course-image-height: ($baseline*8); -$course-info-height: ($baseline*10); -$course-title-height: ($baseline*3.6); $learn-more-horizontal-position: calc(50% - 100px); // calculate the left position for "LEARN MORE" content .courses-container { @@ -13,30 +9,15 @@ $learn-more-horizontal-position: calc(50% - 100px); // calculate the left positi .courses { @include row(); + @include float(left); + width:100%; .courses-listing { @extend %ui-no-list; .courses-listing-item { - @include rtl() { $layout-direction: "RTL"; } - @include fill-parent(); margin: ($baseline*0.75) 0 ($baseline*1.5) 0; max-height: $course-card-height; - - @include media($bp-medium) { - @include span-columns(4); // 4 of 8 - @include omega(2n); - } - - @include media($bp-large) { - @include span-columns(4); // 4 of 12 - @include omega(3n); - } - - @include media($bp-huge) { - @include span-columns(3); // 3 of 12 - @include omega(4n); - } } } @@ -162,3 +143,26 @@ $learn-more-horizontal-position: calc(50% - 100px); // calculate the left positi } } } + +/* Set homepage specific media queries */ +.home .courses-container .courses .courses-listing .courses-listing-item { + + @include rtl() { $layout-direction: "RTL"; } + @include fill-parent(); + + @include media($bp-medium) { + @include span-columns(4); // 4 of 8 + @include omega(2n); + } + + @include media($bp-large) { + @include span-columns(4); // 4 of 12 + @include omega(3n); + } + + @include media($bp-huge) { + @include span-columns(3); // 3 of 12 + @include omega(4n); + } +} + diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index bd19899780..6bea70be95 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -1,4 +1,5 @@ <%! + import json from django.utils.translation import ugettext as _ from microsite_configuration import microsite %> @@ -6,6 +7,25 @@ <%namespace name='static' file='../static_content.html'/> +<%block name="header_extras"> + % for template_name in ["result_item", "filter_bar", "filter", "search_facets_list", "search_facets_section", "search_facet", "more_less_links"]: + + % endfor + + + +<%block name="js_extra"> + <%static:js group='discovery'/> + + <%block name="pagetitle">${_("Courses")} <% platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME) @@ -45,15 +65,38 @@
    + + + +
    +
    +
      - %for course in courses: + %for course in courses:
    • <%include file="../course.html" args="course=course" />
    • %endfor
    -
    + + + + diff --git a/lms/templates/discovery/filter.underscore b/lms/templates/discovery/filter.underscore new file mode 100644 index 0000000000..3c823be75d --- /dev/null +++ b/lms/templates/discovery/filter.underscore @@ -0,0 +1,4 @@ + diff --git a/lms/templates/discovery/filter_bar.underscore b/lms/templates/discovery/filter_bar.underscore new file mode 100644 index 0000000000..ab44d99ffa --- /dev/null +++ b/lms/templates/discovery/filter_bar.underscore @@ -0,0 +1,5 @@ + + + + diff --git a/lms/templates/discovery/more_less_links.underscore b/lms/templates/discovery/more_less_links.underscore new file mode 100644 index 0000000000..54d6816d62 --- /dev/null +++ b/lms/templates/discovery/more_less_links.underscore @@ -0,0 +1,8 @@ +
    + + +
    diff --git a/lms/templates/discovery/result_item.underscore b/lms/templates/discovery/result_item.underscore new file mode 100644 index 0000000000..1a4985d5ee --- /dev/null +++ b/lms/templates/discovery/result_item.underscore @@ -0,0 +1,25 @@ +
    + +
    +
    + <%= content.display_name %> <%= content.number %> + +
    +
    + +
    +
      +
    • <%= org %>
    • +
    • <%= content.number %>
    • +
    • <%= gettext("Starts") %>
    • +
    +
    +
    +
    diff --git a/lms/templates/discovery/search_facet.underscore b/lms/templates/discovery/search_facet.underscore new file mode 100644 index 0000000000..04db2bcfe4 --- /dev/null +++ b/lms/templates/discovery/search_facet.underscore @@ -0,0 +1,6 @@ + diff --git a/lms/templates/discovery/search_facets_list.underscore b/lms/templates/discovery/search_facets_list.underscore new file mode 100644 index 0000000000..1ecbb1eb1b --- /dev/null +++ b/lms/templates/discovery/search_facets_list.underscore @@ -0,0 +1,5 @@ +

    + <%= gettext('Refine your search') %> +

    +
    +
    diff --git a/lms/templates/discovery/search_facets_section.underscore b/lms/templates/discovery/search_facets_section.underscore new file mode 100644 index 0000000000..6ac8874c55 --- /dev/null +++ b/lms/templates/discovery/search_facets_section.underscore @@ -0,0 +1,5 @@ +

    + <%= displayName %> +

    + diff --git a/lms/templates/index.html b/lms/templates/index.html index 7a4a1ea719..817b990edd 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -57,6 +57,7 @@ % endif +
    diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 010d875c54..7eb69b0a9d 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -46,7 +46,7 @@ git+https://github.com/hmarr/django-debug-toolbar-mongo.git@b0686a76f1ce3532088c -e git+https://github.com/edx/edx-val.git@b1e11c9af3233bc06a17acbb33179f46d43c3b87#egg=edx-val -e git+https://github.com/pmitros/RecommenderXBlock.git@e1697b648bc347a65fc24265501355375ff739a2#egg=recommender-xblock -e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones --e git+https://github.com/edx/edx-search.git@ae459ead41962c656ce794619f58cdae46eb7896#egg=edx-search +-e git+https://github.com/edx/edx-search.git@e8b7c262adb500dbb0eced5434a26d9fa2d99dc3#egg=edx-search git+https://github.com/edx/edx-lint.git@8bf82a32ecb8598c415413df66f5232ab8d974e9#egg=edx_lint==0.2.1 -e git+https://github.com/edx/xblock-utils.git@db22bc40fd2a75458a3c66d057f88aff5a7383e6#egg=xblock-utils -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive