Skip to content
Snippets Groups Projects
Commit 65eb26b7 authored by Davorin Šego's avatar Davorin Šego
Browse files

Merge pull request #9004 from edx/dsego/discovery-ui-improvements

Improve course discovery filtering and empty search
parents bf00fe0e 8d6df183
No related merge requests found
Showing
with 519 additions and 367 deletions
......@@ -217,7 +217,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
# assert that the course discovery UI is not present
self.assertNotIn('Search for a course', response.content)
self.assertNotIn('<aside aria-label="Refine your search" class="search-facets phone-menu">', response.content)
self.assertNotIn('<aside aria-label="Refine Your Search" class="search-facets phone-menu">', response.content)
# make sure we have the special css class on the section
self.assertIn('<div class="courses no-course-discovery"', response.content)
......@@ -241,7 +241,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
# assert that the course discovery UI is present
self.assertIn('Search for a course', response.content)
self.assertIn('<aside aria-label="Refine your search" class="search-facets phone-menu">', response.content)
self.assertIn('<aside aria-label="Refine Your Search" class="search-facets phone-menu">', response.content)
self.assertIn('<div class="courses"', response.content)
@patch('student.views.render_to_response', RENDER_MOCK)
......
......@@ -2,13 +2,13 @@
define([
'backbone',
'js/discovery/result'
], function (Backbone, Result) {
'js/discovery/models/course_card'
], function (Backbone, CourseCard) {
'use strict';
return Backbone.Collection.extend({
model: Result,
model: CourseCard,
pageSize: 20,
totalCount: 0,
latestModelsCount: 0,
......
;(function (define) {
define(['backbone', 'js/discovery/models/filter'], function (Backbone, Filter) {
'use strict';
return Backbone.Collection.extend({
model: Filter,
getTerms: function () {
return this.reduce(function (terms, filter) {
terms[filter.id] = filter.get('query');
return terms;
}, {});
}
});
});
})(define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone', 'js/discovery/collection', 'js/discovery/form', 'js/discovery/result_list_view',
'js/discovery/filter_bar_view', 'js/discovery/search_facets_view'],
function(Backbone, Collection, Form, ResultListView, FilterBarView, FacetsBarView) {
define(['backbone', 'js/discovery/models/search_state', 'js/discovery/collections/filters',
'js/discovery/views/search_form', 'js/discovery/views/courses_listing',
'js/discovery/views/filter_bar', 'js/discovery/views/refine_sidebar'],
function(Backbone, SearchState, Filters, SearchForm, CoursesListing, FilterBar, RefineSidebar) {
return function (meanings, 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);
var dispatcher = _.extend({}, Backbone.Events);
var search = new SearchState();
var filters = new Filters();
var listing = new CoursesListing({ model: search.discovery });
var form = new SearchForm();
var filterBar = new FilterBar({ collection: filters });
var refineSidebar = new RefineSidebar({
collection: search.discovery.facetOptions,
meanings: meanings
});
dispatcher.listenTo(form, 'search', function (query) {
filters.reset();
form.showLoadingIndicator();
filters.changeQueryFilter(query);
search.performSearch(query, filters.getTerms());
});
dispatcher.listenTo(filters, 'search', function (searchTerm, facets) {
collection.performSearch(searchTerm, facets);
dispatcher.listenTo(refineSidebar, 'selectOption', function (type, query, name) {
form.showLoadingIndicator();
if (filters.get(type)) {
removeFilter(type);
}
else {
filters.add({type: type, query: query, name: name});
search.refineSearch(filters.getTerms());
}
});
dispatcher.listenTo(filterBar, 'clearFilter', removeFilter);
dispatcher.listenTo(filterBar, 'clearAll', function () {
form.doSearch('');
});
dispatcher.listenTo(filters, 'clear', function () {
form.clearSearch();
collection.performSearch();
filters.hideClearAllButton();
dispatcher.listenTo(listing, 'next', function () {
search.loadNextPage()
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
form.showLoadingIndicator();
dispatcher.listenTo(search, 'next', function () {
listing.renderNext();
});
dispatcher.listenTo(collection, 'search', function () {
if (collection.length > 0) {
form.showFoundMessage(collection.totalCount);
results.render();
dispatcher.listenTo(search, 'search', function (query, total) {
if (total > 0) {
form.showFoundMessage(total);
if (query) {
filters.add(
{type: 'search_query', query: query, name: quote(query)},
{merge: true}
);
}
}
else {
form.showNotFoundMessage(collection.searchTerm);
form.showNotFoundMessage(query);
filters.reset();
}
facetsBarView.renderFacets(collection.facets);
form.hideLoadingIndicator();
listing.render();
refineSidebar.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
form.hideLoadingIndicator();
});
dispatcher.listenTo(collection, 'error', function () {
dispatcher.listenTo(search, 'error', function () {
form.showErrorMessage();
form.hideLoadingIndicator();
});
dispatcher.listenTo(facetsBarView, 'addFilter', function (data) {
filters.addFilter(data);
});
// kick off search on page refresh
form.doSearch(searchQuery);
function removeFilter(type) {
form.showLoadingIndicator();
filters.remove(type);
if (type === 'search_query') {
form.doSearch('');
}
else {
search.refineSearch(filters.getTerms());
}
}
function quote(string) {
return '"'+string+'"';
}
};
});
......
;(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([
'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([
'underscore',
'backbone',
'js/discovery/models/course_card',
'js/discovery/models/facet_option',
], function (_, Backbone, CourseCard, FacetOption) {
'use strict';
return Backbone.Model.extend({
url: '/search/course_discovery/',
jqhxr: null,
defaults: {
totalCount: 0,
latestCount: 0
},
initialize: function () {
this.courseCards = new Backbone.Collection([], { model: CourseCard });
this.facetOptions = new Backbone.Collection([], { model: FacetOption });
},
parse: function (response) {
var courses = response.results || [];
var facets = response.facets || {};
this.courseCards.add(_.pluck(courses, 'data'));
this.set({
totalCount: response.total,
latestCount: courses.length
});
var options = this.facetOptions;
_(facets).each(function (obj, key) {
_(obj.terms).each(function (count, term) {
options.add({
facet: key,
term: term,
count: count
}, {merge: true});
});
});
},
reset: function () {
this.set({
totalCount: 0,
latestCount: 0
});
this.courseCards.reset();
this.facetOptions.reset();
},
latest: function () {
return this.courseCards.last(this.get('latestCount'));
}
});
});
})(define || RequireJS.define);
;(function (define) {
define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Model.extend({
idAttribute: 'term',
defaults: {
facet: '',
term: '',
count: 0,
selected: false
}
});
});
})(define || RequireJS.define);
......@@ -4,13 +4,11 @@ define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Model.extend({
idAttribute: 'type',
defaults: {
type: 'search_query',
query: '',
type: 'search_string'
},
cleanModelView: function() {
this.destroy();
name: ''
}
});
......
;(function (define) {
define([
'underscore',
'backbone',
'js/discovery/models/course_discovery',
'js/discovery/collections/filters'
], function (_, Backbone, CourseDiscovery, Filters) {
'use strict';
return Backbone.Model.extend({
page: 0,
pageSize: 20,
searchTerm: '',
terms: {},
jqhxr: null,
initialize: function () {
this.discovery = new CourseDiscovery();
this.listenTo(this.discovery, 'sync', this.onSync, this);
this.listenTo(this.discovery, 'error', this.onError, this);
},
performSearch: function (searchTerm, otherTerms) {
this.reset();
this.searchTerm = searchTerm;
if (otherTerms) {
this.terms = otherTerms;
}
this.sendQuery(this.buildQuery(0));
},
refineSearch: function (terms) {
this.reset();
this.terms = terms;
this.sendQuery(this.buildQuery(0));
},
loadNextPage: function () {
if (this.hasNextPage()) {
this.sendQuery(this.buildQuery(this.page+1));
}
},
// private
hasNextPage: function () {
var total = this.discovery.get('totalCount');
return total - ((this.page+1) * this.pageSize) > 0;
},
sendQuery: function (data) {
this.jqhxr && this.jqhxr.abort();
this.jqhxr = this.discovery.fetch({
type: 'POST',
data: data
});
return this.jqhxr;
},
buildQuery: function (pageIndex) {
var data = {
search_string: this.searchTerm,
page_size: this.pageSize,
page_index: pageIndex
};
_.extend(data, this.terms);
return data;
},
reset: function () {
this.discovery.reset();
this.page = 0;
},
onError: function (collection, response, options) {
if (response.statusText !== 'abort') {
this.trigger('error');
}
},
onSync: function (collection, response, options) {
var total = this.discovery.get('totalCount');
var originalSearchTerm = this.searchTerm;
if (options.data.page_index === 0) {
if (total === 0) {
// list all courses
this.cachedDiscovery().done(function (cached) {
this.discovery.courseCards.reset(cached.courseCards.toJSON());
this.discovery.facetOptions.reset(cached.facetOptions.toJSON());
this.discovery.set('latestCount', cached.get('latestCount'));
this.trigger('search', originalSearchTerm, total);
});
this.searchTerm = '';
this.terms = {};
}
else {
_.each(this.terms, function (term, facet) {
if (facet !== 'search_query') {
var option = this.discovery.facetOptions.findWhere({
facet: facet,
term: term
})
if (option) {
option.set('selected', true);
}
}
}, this);
this.trigger('search', this.searchTerm, total);
}
}
else {
this.page = options.data.page_index;
this.trigger('next');
}
},
// lazy load
cachedDiscovery: function () {
var deferred = $.Deferred();
var self = this;
if (this.cached) {
deferred.resolveWith(this, [this.cached]);
}
else {
this.cached = new CourseDiscovery();
this.cached.fetch({
type: 'POST',
data: {
search_string: '',
page_size: this.pageSize,
page_index: 0
},
success: function(model, response, options) {
deferred.resolveWith(self, [model]);
}
});
}
return deferred.promise();
}
});
});
})(define || RequireJS.define);
;(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);
......@@ -28,7 +28,7 @@ define([
return Backbone.View.extend({
tagName: 'li',
templateId: '#result_item-tpl',
templateId: '#course_card-tpl',
className: 'courses-listing-item',
initialize: function () {
......
......@@ -5,8 +5,8 @@ define([
'underscore',
'backbone',
'gettext',
'js/discovery/result_item_view'
], function ($, _, Backbone, gettext, ResultItemView) {
'js/discovery/views/course_card'
], function ($, _, Backbone, gettext, CourseCardView) {
'use strict';
return Backbone.View.extend({
......@@ -32,48 +32,29 @@ define([
},
renderItems: function () {
var latest = this.collection.latestModels();
var latest = this.model.latest();
var items = latest.map(function (result) {
var item = new ResultItemView({ model: result });
var item = new CourseCardView({ model: result });
return item.render().el;
}, this);
this.$list.append(items);
},
attachScrollHandler: function () {
this.nextScrollEvent = true;
this.$window.on('scroll', this.scrollHandler.bind(this));
this.$window.on('scroll', _.throttle(this.scrollHandler.bind(this), 400));
},
scrollHandler: function () {
if (this.nextScrollEvent) {
setTimeout(this.throttledScrollHandler.bind(this), 400);
this.nextScrollEvent = false;
}
},
throttledScrollHandler: function () {
if (this.isNearBottom()) {
this.scrolledToBottom();
if (this.isNearBottom() && !this.isLoading) {
this.trigger('next');
this.isLoading = true;
}
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();
}
});
......
;(function (define) {
define([
'jquery',
'underscore',
'backbone',
'gettext',
'js/discovery/models/filter',
'js/discovery/views/filter_label'
], function ($, _, Backbone, gettext, Filter, FilterLabel) {
'use strict';
return Backbone.View.extend({
el: '#filter-bar',
templateId: '#filter_bar-tpl',
events: {
'click #clear-all-filters': 'clearAll',
'click li .discovery-button': 'clearFilter'
},
initialize: function () {
this.tpl = _.template($(this.templateId).html());
this.render();
this.listenTo(this.collection, 'remove', this.hideIfEmpty);
this.listenTo(this.collection, 'add', this.addFilter);
this.listenTo(this.collection, 'reset', this.resetFilters);
},
render: function () {
this.$el.html(this.tpl());
this.$ul = this.$el.find('ul');
this.$el.addClass('is-animated');
return this;
},
addFilter: function (filter) {
var label = new FilterLabel({model: filter});
this.$ul.append(label.render().el);
this.show();
},
hideIfEmpty: function () {
if (this.collection.isEmpty()) {
this.hide();
}
},
resetFilters: function () {
this.$ul.empty();
this.hide();
},
clearFilter: function (event) {
var $target = $(event.currentTarget);
var filter = this.collection.get($target.data('type'));
this.trigger('clearFilter', filter.id);
},
clearAll: function (event) {
this.trigger('clearAll');
},
show: function () {
this.$el.removeClass('is-collapsed');
},
hide: function () {
this.$ul.empty();
this.$el.addClass('is-collapsed');
}
});
});
})(define || RequireJS.define);
......@@ -4,7 +4,7 @@ define([
'jquery',
'underscore',
'backbone',
'gettext',
'gettext'
], function ($, _, Backbone, gettext) {
'use strict';
......@@ -15,21 +15,17 @@ define([
className: 'active-filter',
initialize: function () {
this.tpl = _.template($(this.templateId).html());
this.listenTo(this.model, 'destroy', this.remove);
this.tpl = _.template($('#filter-tpl').html());
this.listenTo(this.model, 'remove', this.remove);
this.listenTo(this.model, 'change', this.render);
},
render: function () {
this.className = this.model.get('type');
var data = this.model.attributes;
var data = _.clone(this.model.attributes);
data.name = data.name || data.query;
this.className = data.type;
this.$el.html(this.tpl(data));
return this;
},
remove: function() {
this.stopListening();
this.$el.remove();
}
});
......
;(function (define) {
define([
'jquery',
'underscore',
'backbone',
'gettext'
], function ($, _, Backbone, gettext) {
'use strict';
return Backbone.View.extend({
el: '.search-facets',
events: {
'click li button': 'selectOption',
'click .show-less': 'collapse',
'click .show-more': 'expand'
},
initialize: function (options) {
this.meanings = options.meanings || {}
this.$container = this.$el.find('.search-facets-lists');
this.facetTpl = _.template($('#facet-tpl').html());
this.facetOptionTpl = _.template($('#facet_option-tpl').html());
},
facetName: function (key) {
return this.meanings[key] && this.meanings[key].name || key;
},
termName: function (facetKey, termKey) {
return this.meanings[facetKey] &&
this.meanings[facetKey].terms &&
this.meanings[facetKey].terms[termKey] || termKey;
},
renderOptions: function (options) {
var html = _.map(options, function(option) {
var data = _.clone(option.attributes);
data.name = this.termName(data.facet, data.term);
return this.facetOptionTpl(data);
}, this).join('');
return html;
},
renderFacet: function (facetKey, options) {
return this.facetTpl({
name: facetKey,
displayName: this.facetName(facetKey),
options: this.renderOptions(options),
listIsHuge: (options.length > 9)
});
},
render: function () {
var grouped = this.collection.groupBy('facet');
var html = _.map(grouped, function(options, facetKey) {
if (options.length > 0) {
return this.renderFacet(facetKey, options);
}
}, this).join('');
this.$container.html(html);
return this;
},
collapse: function (event) {
var $el = $(event.currentTarget),
$more = $el.siblings('.show-more'),
$ul = $el.parent().siblings('ul');
$ul.addClass('collapse');
$el.addClass('hidden');
$more.removeClass('hidden');
},
expand: function (event) {
var $el = $(event.currentTarget),
$ul = $el.parent('div').siblings('ul');
$el.addClass('hidden');
$ul.removeClass('collapse');
$el.siblings('.show-less').removeClass('hidden');
},
selectOption: function (event) {
var $target = $(event.currentTarget);
this.trigger(
'selectOption',
$target.data('facet'),
$target.data('value'),
$target.data('text')
);
},
});
});
})(define || RequireJS.define);
;(function (define) {
define(['jquery', 'backbone'], function ($, Backbone) {
define(['jquery', 'backbone', 'gettext'], function ($, Backbone, gettext) {
'use strict';
return Backbone.View.extend({
el: '#discovery-form',
events: {
'submit form': 'submitForm',
'submit form': 'submitForm'
},
initialize: function () {
......@@ -23,23 +23,20 @@ define(['jquery', 'backbone'], function ($, Backbone) {
},
doSearch: function (term) {
if (term) {
if (term !== undefined) {
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');
},
......@@ -62,6 +59,7 @@ define(['jquery', 'backbone'], function ($, Backbone) {
[_.escape(term)]
);
this.$message.html(msg);
this.clearSearch();
},
showErrorMessage: function () {
......
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