Skip to content
Snippets Groups Projects
Commit 62797a3f authored by dino-cikatic's avatar dino-cikatic
Browse files

Merge pull request #7697 from edx/feature/course-discovery

Feature/course discovery
parents 8a7193e4 7f633fc0
No related merge requests found
Showing
with 886 additions and 10 deletions
......@@ -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()
......
"""
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()
"""
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)
......@@ -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)
......
......@@ -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'))
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)
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_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):
......
......@@ -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'
}
}
......
......@@ -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
......
;(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);
;(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);
;(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);
;(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);
;(function (define) {
define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Model.extend({
defaults: {
query: '',
type: 'search_string'
},
cleanModelView: function() {
this.destroy();
}
});
});
})(define || RequireJS.define);
;(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);
;(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);
;(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);
;(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);
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')
);
});
;(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);
;(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);
;(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);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment