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

Feature/dashboard search
This commit is contained in:
Martyn James
2015-04-27 09:46:35 -04:00
51 changed files with 1565 additions and 569 deletions

View File

@@ -0,0 +1,12 @@
<div id="courseware-search-bar" class="search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">Course Search</label>
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
search <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</form>
</div>

View File

@@ -0,0 +1,14 @@
<div id="dashboard-search-bar" class="search-bar" role="search" aria-label="Dashboard">
<form>
<label for="dashboard-search-input">Search Your Courses</label>
<div>
<input id="dashboard-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">
<i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="Clear search">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>

View File

@@ -1,7 +0,0 @@
<div id="courseware-search-bar" class="search-container">
<form role="search-form">
<input type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">search <i class="icon-search"></i></button>
<button type="button" class="cancel-button" aria-label="Cancel"><i class="icon-remove"></i></button>
</form>
</div>

View File

@@ -2,7 +2,7 @@
define([
'backbone',
'js/search/models/search_result'
'js/search/base/models/search_result'
], function (Backbone, SearchResult) {
'use strict';
@@ -11,6 +11,7 @@ define([
model: SearchResult,
pageSize: 20,
totalCount: 0,
latestModelsCount: 0,
accessDeniedCount: 0,
searchTerm: '',
page: 0,
@@ -20,17 +21,15 @@ define([
initialize: function (models, options) {
// call super constructor
Backbone.Collection.prototype.initialize.apply(this, arguments);
if (options && options.course_id) {
this.url += options.course_id;
if (options && options.courseId) {
this.url += options.courseId;
}
},
performSearch: function (searchTerm) {
this.fetchXhr && this.fetchXhr.abort();
this.searchTerm = searchTerm || '';
this.totalCount = 0;
this.accessDeniedCount = 0;
this.page = 0;
this.resetState();
this.fetchXhr = this.fetch({
data: {
search_string: searchTerm,
@@ -71,20 +70,34 @@ define([
cancelSearch: function () {
this.fetchXhr && this.fetchXhr.abort();
this.page = 0;
this.totalCount = 0;
this.accessDeniedCount = 0;
this.resetState();
},
parse: function(response) {
this.latestModelsCount = response.results.length;
this.totalCount = response.total;
this.accessDeniedCount += response.access_denied_count;
this.totalCount -= this.accessDeniedCount;
return _.map(response.results, function(result){ return result.data; });
return _.map(response.results, function (result) {
return result.data;
});
},
resetState: function () {
this.page = 0;
this.totalCount = 0;
this.latestModelsCount = 0;
this.accessDeniedCount = 0;
// empty the entire collection
this.reset();
},
hasNextPage: function () {
return this.totalCount - ((this.page + 1) * this.pageSize) > 0;
},
latestModels: function () {
return this.last(this.latestModelsCount);
}
});

View File

@@ -4,9 +4,12 @@ define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Router.extend({
routes: {
'search/:query': 'search'
}
routes: {
'search/:query': 'search'
},
search: function(query) {
this.trigger('search', query);
}
});
});

View File

@@ -5,7 +5,7 @@ define(['jquery', 'backbone'], function ($, Backbone) {
return Backbone.View.extend({
el: '#courseware-search-bar',
el: '',
events: {
'submit form': 'submitForm',
'click .cancel-button': 'clearSearch',
@@ -40,9 +40,13 @@ define(['jquery', 'backbone'], function ($, Backbone) {
}
},
clearSearch: function () {
resetSearchForm: function () {
this.$searchField.val('');
this.setInitialStyle();
},
clearSearch: function () {
this.resetSearchForm();
this.trigger('clear');
},

View File

@@ -7,11 +7,12 @@ define([
'gettext',
'logger'
], function ($, _, Backbone, gettext, Logger) {
'use strict';
'use strict';
return Backbone.View.extend({
tagName: 'li',
templateId: '',
className: 'search-results-item',
attributes: {
'role': 'region',
@@ -19,16 +20,22 @@ define([
},
events: {
'click .search-results-item a': 'logSearchItem',
'click': 'logSearchItem',
},
initialize: function () {
var template_name = (this.model.attributes.content_type === "Sequence") ? '#search_item_seq-tpl' : '#search_item-tpl';
this.tpl = _.template($(template_name).html());
this.tpl = _.template($(this.templateId).html());
},
render: function () {
this.$el.html(this.tpl(this.model.attributes));
var data = _.clone(this.model.attributes);
// Drop the preview text and result type if the search term is found
// in the title/location in the course hierarchy
if (this.model.get('content_type') === 'Sequence') {
data.excerpt = '';
data.content_type = '';
}
this.$el.html(this.tpl(data));
return this;
},
@@ -44,24 +51,23 @@ define([
event.preventDefault();
var self = this;
var target = this.model.id;
var link = $(event.target).attr('href');
var link = this.model.get('url');
var collection = this.model.collection;
var page = collection.page;
var pageSize = collection.pageSize;
var searchTerm = collection.searchTerm;
var index = collection.indexOf(this.model);
Logger.log("edx.course.search.result_selected",
{
"search_term": searchTerm,
"result_position": (page * pageSize + index),
"result_link": target
}).always(function() {
self.redirect(link);
});
Logger.log('edx.course.search.result_selected', {
'search_term': searchTerm,
'result_position': (page * pageSize + index),
'result_link': target
}).always(function() {
self.redirect(link);
});
}
});
});
})(define || RequireJS.define);

View File

@@ -5,39 +5,39 @@ define([
'underscore',
'backbone',
'gettext',
'js/search/views/search_item_view'
], function ($, _, Backbone, gettext, SearchItemView) {
], function ($, _, Backbone, gettext) {
'use strict';
return Backbone.View.extend({
el: '#courseware-search-results',
events: {
'click .search-load-next': 'loadNext'
},
spinner: '.icon',
// these should be defined by subclasses
el: '',
contentElement: '',
resultsTemplateId: '',
loadingTemplateId: '',
errorTemplateId: '',
events: {},
spinner: '.search-load-next .icon',
SearchItemView: function () {},
initialize: function () {
this.courseName = this.$el.attr('data-course-name');
this.$courseContent = $('#course-content');
this.listTemplate = _.template($('#search_list-tpl').html());
this.loadingTemplate = _.template($('#search_loading-tpl').html());
this.errorTemplate = _.template($('#search_error-tpl').html());
this.collection.on('search', this.render, this);
this.collection.on('next', this.renderNext, this);
this.collection.on('error', this.showErrorMessage, this);
this.$contentElement = $(this.contentElement);
this.resultsTemplate = _.template($(this.resultsTemplateId).html());
this.loadingTemplate = _.template($(this.loadingTemplateId).html());
this.errorTemplate = _.template($(this.errorTemplateId).html());
},
render: function () {
this.$el.html(this.listTemplate({
this.$el.html(this.resultsTemplate({
totalCount: this.collection.totalCount,
totalCountMsg: this.totalCountMsg(),
pageSize: this.collection.pageSize,
hasMoreResults: this.collection.hasNextPage()
}));
this.renderItems();
this.$courseContent.hide();
this.$el.find(this.spinner).hide();
this.$contentElement.hide();
this.$el.show();
return this;
},
@@ -53,11 +53,12 @@ define([
},
renderItems: function () {
var items = this.collection.map(function (result) {
var item = new SearchItemView({ model: result });
var latest = this.collection.latestModels();
var items = latest.map(function (result) {
var item = new this.SearchItemView({ model: result });
return item.render().el;
});
this.$el.find('.search-results').html(items);
}, this);
this.$el.find('ol').append(items);
},
totalCountMsg: function () {
@@ -67,25 +68,26 @@ define([
clear: function () {
this.$el.hide().empty();
this.$courseContent.show();
this.$contentElement.show();
},
showLoadingMessage: function () {
this.$el.html(this.loadingTemplate());
this.$el.show();
this.$courseContent.hide();
this.$contentElement.hide();
},
showErrorMessage: function () {
this.$el.html(this.errorTemplate());
this.$el.show();
this.$courseContent.hide();
this.$contentElement.hide();
},
loadNext: function (event) {
event && event.preventDefault();
this.$el.find(this.spinner).show();
this.trigger('next');
return false;
}
});

View File

@@ -0,0 +1,22 @@
RequireJS.require([
'jquery',
'backbone',
'js/search/course/search_app',
'js/search/base/routers/search_router',
'js/search/course/views/search_form',
'js/search/base/collections/search_collection',
'js/search/course/views/search_results_view'
], function ($, Backbone, SearchApp, SearchRouter, CourseSearchForm, SearchCollection, CourseSearchResultsView) {
'use strict';
var courseId = $('#courseware-search-results').data('courseId');
var app = new SearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
Backbone.history.start();
});

View File

@@ -0,0 +1,50 @@
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (courseId, SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([], { courseId: courseId });
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,14 @@
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#courseware-search-bar'
});
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,14 @@
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#course_search_item-tpl'
});
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,26 @@
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/course/views/search_item_view'
], function (SearchResultsView, CourseSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#courseware-search-results',
contentElement: '#course-content',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
},
SearchItemView: CourseSearchItemView
});
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,19 @@
RequireJS.require([
'backbone',
'js/search/dashboard/search_app',
'js/search/base/routers/search_router',
'js/search/dashboard/views/search_form',
'js/search/base/collections/search_collection',
'js/search/dashboard/views/search_results_view'
], function (Backbone, SearchApp, SearchRouter, DashSearchForm, SearchCollection, DashSearchResultsView) {
'use strict';
var app = new SearchApp(
SearchRouter,
DashSearchForm,
SearchCollection,
DashSearchResultsView
);
Backbone.history.start();
});

View File

@@ -0,0 +1,54 @@
;(function (define) {
define(['backbone'], function(Backbone) {
'use strict';
return function (SearchRouter, SearchForm, SearchCollection, SearchListView) {
var router = new SearchRouter();
var form = new SearchForm();
var collection = new SearchCollection([]);
var results = new SearchListView({ collection: collection });
var dispatcher = _.clone(Backbone.Events);
dispatcher.listenTo(router, 'search', function (query) {
form.doSearch(query);
});
dispatcher.listenTo(form, 'search', function (query) {
results.showLoadingMessage();
collection.performSearch(query);
router.navigate('search/' + query, { replace: true });
});
dispatcher.listenTo(form, 'clear', function () {
collection.cancelSearch();
results.clear();
router.navigate('');
});
dispatcher.listenTo(results, 'next', function () {
collection.loadNextPage();
});
dispatcher.listenTo(results, 'reset', function () {
form.resetSearchForm();
});
dispatcher.listenTo(collection, 'search', function () {
results.render();
});
dispatcher.listenTo(collection, 'next', function () {
results.renderNext();
});
dispatcher.listenTo(collection, 'error', function () {
results.showErrorMessage();
});
};
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,14 @@
;(function (define) {
define([
'js/search/base/views/search_form'
], function (SearchForm) {
'use strict';
return SearchForm.extend({
el: '#dashboard-search-bar'
});
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,14 @@
;(function (define) {
define([
'js/search/base/views/search_item_view'
], function (SearchItemView) {
'use strict';
return SearchItemView.extend({
templateId: '#dashboard_search_item-tpl'
});
});
})(define || RequireJS.define);

View File

@@ -0,0 +1,33 @@
;(function (define) {
define([
'js/search/base/views/search_results_view',
'js/search/dashboard/views/search_item_view'
], function (SearchResultsView, DashSearchItemView) {
'use strict';
return SearchResultsView.extend({
el: '#dashboard-search-results',
contentElement: '#my-courses, #profile-sidebar',
resultsTemplateId: '#dashboard_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
'click .search-back-to-courses': 'backToCourses'
},
SearchItemView: DashSearchItemView,
backToCourses: function () {
this.clear();
this.trigger('reset');
return false;
}
});
});
})(define || RequireJS.define);

View File

@@ -1,12 +0,0 @@
RequireJS.require([
'jquery',
'backbone',
'js/search/search_app'
], function ($, Backbone, SearchApp) {
'use strict';
var course_id = $('#courseware-search-results').attr('data-course-id');
var app = new SearchApp(course_id);
Backbone.history.start();
});

View File

@@ -1,37 +0,0 @@
;(function (define) {
define([
'backbone',
'js/search/search_router',
'js/search/views/search_form',
'js/search/views/search_list_view',
'js/search/collections/search_collection'
], function(Backbone, SearchRouter, SearchForm, SearchListView, SearchCollection) {
'use strict';
return function (course_id) {
var self = this;
this.router = new SearchRouter();
this.form = new SearchForm();
this.collection = new SearchCollection([], { course_id: course_id });
this.results = new SearchListView({ collection: this.collection });
this.form.on('search', this.results.showLoadingMessage, this.results);
this.form.on('search', this.collection.performSearch, this.collection);
this.form.on('search', function (term) {
self.router.navigate('search/' + term, { replace: true });
});
this.form.on('clear', this.collection.cancelSearch, this.collection);
this.form.on('clear', this.results.clear, this.results);
this.form.on('clear', this.router.navigate, this.router);
this.results.on('next', this.collection.loadNextPage, this.collection);
this.router.on('route:search', this.form.doSearch, this.form);
};
});
})(define || RequireJS.define);

View File

@@ -4,136 +4,37 @@ define([
'backbone',
'logger',
'js/common_helpers/template_helpers',
'js/search/views/search_form',
'js/search/views/search_item_view',
'js/search/views/search_list_view',
'js/search/models/search_result',
'js/search/collections/search_collection',
'js/search/search_router',
'js/search/search_app'
'js/search/base/models/search_result',
'js/search/base/collections/search_collection',
'js/search/base/routers/search_router',
'js/search/course/views/search_item_view',
'js/search/dashboard/views/search_item_view',
'js/search/course/views/search_form',
'js/search/dashboard/views/search_form',
'js/search/course/views/search_results_view',
'js/search/dashboard/views/search_results_view',
'js/search/course/search_app',
'js/search/dashboard/search_app'
], function(
$,
Sinon,
Backbone,
Logger,
TemplateHelpers,
SearchForm,
SearchItemView,
SearchListView,
SearchResult,
SearchCollection,
SearchRouter,
SearchApp
CourseSearchItemView,
DashSearchItemView,
CourseSearchForm,
DashSearchForm,
CourseSearchResultsView,
DashSearchResultsView,
CourseSearchApp,
DashSearchApp
) {
'use strict';
describe('SearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search_form.html');
this.form = new SearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', function () {
var term = ' search string ';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
});
it('handles calls to doSearch', function () {
var term = ' search string ';
$('.search-field').val(term);
this.form.doSearch(term);
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
expect($('.search-field').val()).toEqual(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
});
it('triggers a search event and changes to active state', function () {
var term = 'search string';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
});
it('clears search when clicking on cancel button', function () {
$('.search-field').val('search string');
$('.cancel-button').trigger('click');
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.search-button')).toBeVisible();
expect($('.cancel-button')).toBeHidden();
expect($('.search-field')).toHaveValue('');
});
it('clears search when search box is empty', function() {
$('.search-field').val('');
$('form').trigger('submit');
expect(this.onClear).toHaveBeenCalled();
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.cancel-button')).toBeHidden();
expect($('.search-button')).toBeVisible();
});
});
describe('SearchItemView', function () {
beforeEach(function () {
TemplateHelpers.installTemplate('templates/courseware_search/search_item');
TemplateHelpers.installTemplate('templates/courseware_search/search_item_seq');
this.model = {
attributes: {
location: ['section', 'subsection', 'unit'],
content_type: 'Video',
excerpt: 'A short excerpt.',
url: 'path/to/content'
}
};
this.item = new SearchItemView({ model: this.model });
});
it('has useful html attributes', function () {
expect(this.item.$el).toHaveAttr('role', 'region');
expect(this.item.$el).toHaveAttr('aria-label', 'search result');
});
it('renders correctly', function () {
var href = this.model.attributes.url;
var breadcrumbs = 'section ▸ subsection ▸ unit';
this.item.render();
expect(this.item.$el).toContainHtml(this.model.attributes.content_type);
expect(this.item.$el).toContainHtml(this.model.attributes.excerpt);
expect(this.item.$el).toContain('a[href="'+href+'"]');
expect(this.item.$el).toContainHtml(breadcrumbs);
});
it('log request on follow item link', function () {
this.model.collection = new SearchCollection([this.model], { course_id: 'edx101' });
this.item.render();
// Mock the redirect call
spyOn(this.item, 'redirect').andCallFake( function() {} );
spyOn(this.item, 'logSearchItem').andCallThrough();
spyOn(Logger, 'log').andReturn($.Deferred().resolve());
var link = this.item.$el.find('a');
expect(link.length).toBe(1);
link.trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
});
});
describe('SearchResult', function () {
@@ -171,9 +72,16 @@ define([
this.server.restore();
});
it('appends course_id to url', function () {
var collection = new SearchCollection([], { course_id: 'edx101' });
expect(collection.url).toEqual('/search/edx101');
it('sends a request without a course ID', function () {
var collection = new SearchCollection([]);
collection.performSearch('search string');
expect(this.server.requests[0].url).toEqual('/search/');
});
it('sends a request with course ID', function () {
var collection = new SearchCollection([], { courseId: 'edx101' });
collection.performSearch('search string');
expect(this.server.requests[0].url).toEqual('/search/edx101');
});
it('sends a request and parses the json result', function () {
@@ -195,6 +103,7 @@ define([
expect(this.onSearch).toHaveBeenCalled();
expect(this.collection.totalCount).toEqual(1);
expect(this.collection.latestModelsCount).toEqual(1);
expect(this.collection.accessDeniedCount).toEqual(1);
expect(this.collection.page).toEqual(0);
expect(this.collection.first().attributes).toEqual(response.results[0].data);
@@ -216,8 +125,9 @@ define([
});
it('sends correct paging parameters', function () {
this.collection.performSearch('search string');
var searchString = 'search string';
var response = { total: 52, results: [] };
this.collection.performSearch(searchString);
this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
this.server.respond();
this.collection.loadNextPage();
@@ -225,11 +135,9 @@ define([
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
});
expect($.ajax.mostRecentCall.args[0].data.search_string).toEqual(searchString);
expect($.ajax.mostRecentCall.args[0].data.page_size).toEqual(this.collection.pageSize);
expect($.ajax.mostRecentCall.args[0].data.page_index).toEqual(2);
});
it('has next page', function () {
@@ -266,18 +174,23 @@ define([
beforeEach(function () {
this.collection.page = 2;
this.collection.totalCount = 35;
this.collection.latestModelsCount = 5;
});
it('resets state when performing new search', function () {
this.collection.performSearch('search string');
expect(this.collection.models.length).toEqual(0);
expect(this.collection.page).toEqual(0);
expect(this.collection.totalCount).toEqual(0);
expect(this.collection.latestModelsCount).toEqual(0);
});
it('resets state when canceling a search', function () {
this.collection.cancelSearch();
expect(this.collection.models.length).toEqual(0);
expect(this.collection.page).toEqual(0);
expect(this.collection.totalCount).toEqual(0);
expect(this.collection.latestModelsCount).toEqual(0);
});
});
@@ -285,167 +198,368 @@ define([
});
describe('SearchListView', function () {
beforeEach(function () {
setFixtures(
'<section id="courseware-search-results" data-course-name="Test Course"></section>' +
'<section id="course-content"></section>'
);
TemplateHelpers.installTemplates([
'templates/courseware_search/search_item',
'templates/courseware_search/search_item_seq',
'templates/courseware_search/search_list',
'templates/courseware_search/search_loading',
'templates/courseware_search/search_error'
]);
var MockCollection = Backbone.Collection.extend({
hasNextPage: function (){}
});
this.collection = new MockCollection();
// spy on these methods before they are bound to events
spyOn(SearchListView.prototype, 'render').andCallThrough();
spyOn(SearchListView.prototype, 'renderNext').andCallThrough();
spyOn(SearchListView.prototype, 'showErrorMessage').andCallThrough();
this.listView = new SearchListView({ collection: this.collection });
});
it('shows loading message', function () {
this.listView.showLoadingMessage();
expect($('#course-content')).toBeHidden();
expect(this.listView.$el).toBeVisible();
expect(this.listView.$el).not.toBeEmpty();
});
it('shows error message', function () {
this.listView.showErrorMessage();
expect($('#course-content')).toBeHidden();
expect(this.listView.$el).toBeVisible();
expect(this.listView.$el).not.toBeEmpty();
});
it('returns to content', function () {
this.listView.clear();
expect($('#course-content')).toBeVisible();
expect(this.listView.$el).toBeHidden();
expect(this.listView.$el).toBeEmpty();
});
it('handles events', function () {
this.collection.trigger('search');
this.collection.trigger('next');
this.collection.trigger('error');
expect(this.listView.render).toHaveBeenCalled();
expect(this.listView.renderNext).toHaveBeenCalled();
expect(this.listView.showErrorMessage).toHaveBeenCalled();
});
it('renders a message when there are no results', function () {
this.collection.reset();
this.listView.render();
expect(this.listView.$el).toContainHtml('no results');
expect(this.listView.$el.find('ol')).not.toExist();
});
it('renders search results', function () {
var searchResults = [{
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt'
}];
this.collection.set(searchResults);
this.collection.totalCount = 1;
this.listView.render();
expect(this.listView.$el.find('ol')[0]).toExist();
expect(this.listView.$el.find('li').length).toEqual(1);
expect(this.listView.$el).toContainHtml('Search Results');
expect(this.listView.$el).toContainHtml('this is a short excerpt');
searchResults[1] = searchResults[0]
this.collection.set(searchResults);
this.collection.totalCount = 2;
this.listView.renderNext();
expect(this.listView.$el.find('.search-count')).toContainHtml('2');
expect(this.listView.$el.find('li').length).toEqual(2);
});
it('shows a link to load more results', function () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.listView.render();
expect(this.listView.$el.find('a.search-load-next')[0]).toExist();
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return false; };
this.listView.render();
expect(this.listView.$el.find('a.search-load-next')[0]).not.toExist();
});
it('triggers an event for next page', function () {
var onNext = jasmine.createSpy('onNext');
this.listView.on('next', onNext);
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.listView.render();
this.listView.$el.find('a.search-load-next').click();
expect(onNext).toHaveBeenCalled();
});
it('shows a spinner when loading more results', function () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.listView.render();
this.listView.loadNext();
// Do we really need to check if a loading indicator exists? - CR
// jasmine.Clock.useMock(1000);
// expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeVisible();
this.listView.renderNext();
// jasmine.Clock.useMock(1000);
// expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeHidden();
});
});
describe('SearchRouter', function () {
beforeEach(function () {
this.router = new SearchRouter();
this.onSearch = jasmine.createSpy('onSearch');
this.router.on('search', this.onSearch);
});
it ('has a search route', function () {
expect(this.router.routes['search/:query']).toEqual('search');
});
it ('triggers a search event', function () {
var query = 'mercury';
this.router.search(query);
expect(this.onSearch).toHaveBeenCalledWith(query);
});
});
describe('SearchItemView', function () {
function beforeEachHelper(SearchItemView) {
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item'
]);
this.model = new SearchResult({
location: ['section', 'subsection', 'unit'],
content_type: 'Video',
course_name: 'Course Name',
excerpt: 'A short excerpt.',
url: 'path/to/content'
});
this.seqModel = new SearchResult({
location: ['section', 'subsection'],
content_type: 'Sequence',
course_name: 'Course Name',
excerpt: 'A short excerpt.',
url: 'path/to/content'
});
this.item = new SearchItemView({ model: this.model });
this.item.render();
this.seqItem = new SearchItemView({ model: this.seqModel });
this.seqItem.render();
}
function rendersItem() {
expect(this.item.$el).toHaveAttr('role', 'region');
expect(this.item.$el).toHaveAttr('aria-label', 'search result');
expect(this.item.$el).toContain('a[href="' + this.model.get('url') + '"]');
expect(this.item.$el.find('.result-type')).toContainHtml(this.model.get('content_type'));
expect(this.item.$el.find('.result-excerpt')).toContainHtml(this.model.get('excerpt'));
expect(this.item.$el.find('.result-location')).toContainHtml('section ▸ subsection ▸ unit');
}
function rendersSequentialItem() {
expect(this.seqItem.$el).toHaveAttr('role', 'region');
expect(this.seqItem.$el).toHaveAttr('aria-label', 'search result');
expect(this.seqItem.$el).toContain('a[href="' + this.seqModel.get('url') + '"]');
expect(this.seqItem.$el.find('.result-type')).toBeEmpty();
expect(this.seqItem.$el.find('.result-excerpt')).toBeEmpty();
expect(this.seqItem.$el.find('.result-location')).toContainHtml('section ▸ subsection');
}
function logsSearchItemViewEvent() {
this.model.collection = new SearchCollection([this.model], { course_id: 'edx101' });
this.item.render();
// Mock the redirect call
spyOn(this.item, 'redirect').andCallFake( function() {} );
spyOn(Logger, 'log').andReturn($.Deferred().resolve());
this.item.$el.find('a').trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
this.item.$el.trigger('click');
expect(this.item.redirect).toHaveBeenCalled();
}
describe('CourseSearchItemView', function () {
beforeEach(function () {
beforeEachHelper.call(this, CourseSearchItemView);
});
it('renders items correctly', rendersItem);
it('renders Sequence items correctly', rendersSequentialItem);
it('logs view event', logsSearchItemViewEvent);
});
describe('DashSearchItemView', function () {
beforeEach(function () {
beforeEachHelper.call(this, DashSearchItemView);
});
it('renders items correctly', rendersItem);
it('renders Sequence items correctly', rendersSequentialItem);
it('displays course name in breadcrumbs', function () {
expect(this.seqItem.$el.find('.result-course-name')).toContainHtml(this.model.get('course_name'));
});
it('logs view event', logsSearchItemViewEvent);
});
});
describe('SearchForm', function () {
function trimsInputString() {
var term = ' search string ';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
}
function doesSearch() {
var term = ' search string ';
$('.search-field').val(term);
this.form.doSearch(term);
expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
expect($('.search-field').val()).toEqual(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
}
function triggersSearchEvent() {
var term = 'search string';
$('.search-field').val(term);
$('form').trigger('submit');
expect(this.onSearch).toHaveBeenCalledWith(term);
expect($('.search-field')).toHaveClass('is-active');
expect($('.search-button')).toBeHidden();
expect($('.cancel-button')).toBeVisible();
}
function clearsSearchOnCancel() {
$('.search-field').val('search string');
$('.search-button').trigger('click');
$('.cancel-button').trigger('click');
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.search-button')).toBeVisible();
expect($('.cancel-button')).toBeHidden();
expect($('.search-field')).toHaveValue('');
}
function clearsSearchOnEmpty() {
$('.search-field').val('');
$('form').trigger('submit');
expect(this.onClear).toHaveBeenCalled();
expect($('.search-field')).not.toHaveClass('is-active');
expect($('.cancel-button')).toBeHidden();
expect($('.search-button')).toBeVisible();
}
describe('CourseSearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
this.form = new CourseSearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', trimsInputString);
it('handles calls to doSearch', doesSearch);
it('triggers a search event and changes to active state', triggersSearchEvent);
it('clears search when clicking on cancel button', clearsSearchOnCancel);
it('clears search when search box is empty', clearsSearchOnEmpty);
});
describe('DashSearchForm', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/dashboard_search_form.html');
this.form = new DashSearchForm();
this.onClear = jasmine.createSpy('onClear');
this.onSearch = jasmine.createSpy('onSearch');
this.form.on('clear', this.onClear);
this.form.on('search', this.onSearch);
});
it('trims input string', trimsInputString);
it('handles calls to doSearch', doesSearch);
it('triggers a search event and changes to active state', triggersSearchEvent);
it('clears search when clicking on cancel button', clearsSearchOnCancel);
it('clears search when search box is empty', clearsSearchOnEmpty);
});
});
describe('SearchResultsView', function () {
function showsLoadingMessage () {
this.resultsView.showLoadingMessage();
expect(this.resultsView.$contentElement).toBeHidden();
expect(this.resultsView.$el).toBeVisible();
expect(this.resultsView.$el).not.toBeEmpty();
}
function showsErrorMessage () {
this.resultsView.showErrorMessage();
expect(this.resultsView.$contentElement).toBeHidden();
expect(this.resultsView.$el).toBeVisible();
expect(this.resultsView.$el).not.toBeEmpty();
}
function returnsToContent () {
this.resultsView.clear();
expect(this.resultsView.$contentElement).toBeVisible();
expect(this.resultsView.$el).toBeHidden();
expect(this.resultsView.$el).toBeEmpty();
}
function showsNoResultsMessage() {
this.collection.reset();
this.resultsView.render();
expect(this.resultsView.$el).toContainHtml('no results');
expect(this.resultsView.$el.find('ol')).not.toExist();
}
function rendersSearchResults () {
var searchResults = [{
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
course_name: '',
excerpt: 'this is a short excerpt'
}];
this.collection.set(searchResults);
this.collection.latestModelsCount = 1;
this.collection.totalCount = 1;
this.resultsView.render();
expect(this.resultsView.$el.find('ol')[0]).toExist();
expect(this.resultsView.$el.find('li').length).toEqual(1);
expect(this.resultsView.$el).toContainHtml('Search Results');
expect(this.resultsView.$el).toContainHtml('this is a short excerpt');
this.collection.set(searchResults);
this.collection.totalCount = 2;
this.resultsView.renderNext();
expect(this.resultsView.$el.find('.search-count')).toContainHtml('2');
expect(this.resultsView.$el.find('li').length).toEqual(2);
}
function showsMoreResultsLink () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next')[0]).toExist();
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return false; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next')[0]).not.toExist();
}
function triggersNextPageEvent () {
var onNext = jasmine.createSpy('onNext');
this.resultsView.on('next', onNext);
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
this.resultsView.$el.find('a.search-load-next').click();
expect(onNext).toHaveBeenCalled();
}
function showsLoadMoreSpinner () {
this.collection.totalCount = 123;
this.collection.hasNextPage = function () { return true; };
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
this.resultsView.loadNext();
// toBeVisible does not work with inline
expect(this.resultsView.$el.find('a.search-load-next .icon')).toHaveCss({ 'display': 'inline' });
this.resultsView.renderNext();
expect(this.resultsView.$el.find('a.search-load-next .icon')).toBeHidden();
}
function beforeEachHelper(SearchResultsView) {
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<section id="course-content"></section>' +
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
'templates/search/course_search_results',
'templates/search/dashboard_search_results',
'templates/search/search_list',
'templates/search/search_loading',
'templates/search/search_error'
]);
var MockCollection = Backbone.Collection.extend({
hasNextPage: function () {},
latestModelsCount: 0,
pageSize: 20,
latestModels: function () {
return SearchCollection.prototype.latestModels.apply(this, arguments);
}
});
this.collection = new MockCollection();
this.resultsView = new SearchResultsView({ collection: this.collection });
}
describe('CourseSearchResultsView', function () {
beforeEach(function() {
beforeEachHelper.call(this, CourseSearchResultsView);
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
it('returns to content', returnsToContent);
it('shows a message when there are no results', showsNoResultsMessage);
it('renders search results', rendersSearchResults);
it('shows a link to load more results', showsMoreResultsLink);
it('triggers an event for next page', triggersNextPageEvent);
it('shows a spinner when loading more results', showsLoadMoreSpinner);
});
describe('DashSearchResultsView', function () {
beforeEach(function() {
beforeEachHelper.call(this, DashSearchResultsView);
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
it('returns to content', returnsToContent);
it('shows a message when there are no results', showsNoResultsMessage);
it('renders search results', rendersSearchResults);
it('shows a link to load more results', showsMoreResultsLink);
it('triggers an event for next page', triggersNextPageEvent);
it('shows a spinner when loading more results', showsLoadMoreSpinner);
it('returns back to courses', function () {
var onReset = jasmine.createSpy('onReset');
this.resultsView.on('reset', onReset);
this.resultsView.render();
expect(this.resultsView.$el.find('a.search-back-to-courses')).toExist();
this.resultsView.$el.find('.search-back-to-courses').click();
expect(onReset).toHaveBeenCalled();
expect(this.resultsView.$contentElement).toBeVisible();
expect(this.resultsView.$el).toBeHidden();
});
});
});
describe('SearchApp', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search_form.html');
appendSetFixtures(
'<section id="courseware-search-results" data-course-name="Test Course"></section>' +
'<section id="course-content"></section>'
);
TemplateHelpers.installTemplates([
'templates/courseware_search/search_item',
'templates/courseware_search/search_item_seq',
'templates/courseware_search/search_list',
'templates/courseware_search/search_loading',
'templates/courseware_search/search_error'
]);
function showsLoadingMessage () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
expect(this.$contentElement).toBeHidden();
expect(this.$searchResults).toBeVisible();
expect(this.$searchResults).not.toBeEmpty();
}
this.server = Sinon.fakeServer.create();
function performsSearch () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
@@ -454,82 +568,203 @@ define([
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt'
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
Backbone.history.stop();
this.app = new SearchApp('a/b/c');
// start history after the application has finished creating
// all of its routers
Backbone.history.start();
});
afterEach(function () {
this.server.restore();
});
it ('shows loading message on search', function () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
expect($('#course-content')).toBeHidden();
expect($('#courseware-search-results')).toBeVisible();
expect($('#courseware-search-results')).not.toBeEmpty();
});
it ('performs search', function () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respond();
expect($('.search-info')).toExist();
expect($('.search-results')).toBeVisible();
});
expect($('.search-result-list')).toBeVisible();
expect(this.$searchResults.find('li').length).toEqual(1);
}
it ('updates navigation history on search', function () {
function showsErrorMessage () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respondWith([500, {}]);
this.server.respond();
expect(this.$searchResults).toEqual($('#search_error-tpl'));
}
function updatesNavigationHistory () {
$('.search-field').val('edx');
$('.search-button').trigger('click');
expect(Backbone.history.fragment).toEqual('search/edx');
});
expect(Backbone.history.navigate.calls[0].args).toContain('search/edx');
$('.cancel-button').trigger('click');
expect(Backbone.history.navigate.calls[1].args).toContain('');
}
it ('aborts sent search request', function () {
function cancelsSearchRequest () {
// send search request to server
$('.search-field').val('search string');
$('.search-button').trigger('click');
// cancel search
$('.cancel-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond();
// there should be no results
expect($('#course-content')).toBeVisible();
expect($('#courseware-search-results')).toBeHidden();
});
expect(this.$contentElement).toBeVisible();
expect(this.$searchResults).toBeHidden();
}
it ('clears results', function () {
function clearsResults () {
$('.cancel-button').trigger('click');
expect($('#course-content')).toBeVisible();
expect($('#courseware-search-results')).toBeHidden();
});
expect(this.$contentElement).toBeVisible();
expect(this.$searchResults).toBeHidden();
}
it ('updates navigation history on clear', function () {
$('.cancel-button').trigger('click');
expect(Backbone.history.fragment).toEqual('');
});
it ('loads next page', function () {
function loadsNextPage () {
$('.search-field').val('query');
$('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond();
expect(this.$searchResults.find('li').length).toEqual(1);
expect($('.search-load-next')).toBeVisible();
$('.search-load-next').trigger('click');
var body = this.server.requests[1].requestBody;
expect(body).toContain('search_string=query');
expect(body).toContain('page_index=1');
});
this.server.respond();
expect(this.$searchResults.find('li').length).toEqual(2);
}
it ('navigates to search', function () {
function navigatesToSearch () {
Backbone.history.loadUrl('search/query');
expect(this.server.requests[0].requestBody).toContain('search_string=query');
}
function loadTemplates () {
TemplateHelpers.installTemplates([
'templates/search/course_search_item',
'templates/search/dashboard_search_item',
'templates/search/search_loading',
'templates/search/search_error',
'templates/search/course_search_results',
'templates/search/dashboard_search_results'
]);
}
describe('CourseSearchApp', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<section id="course-content"></section>'
);
loadTemplates.call(this);
this.server = Sinon.fakeServer.create();
var courseId = 'a/b/c';
this.app = new CourseSearchApp(
courseId,
SearchRouter,
CourseSearchForm,
SearchCollection,
CourseSearchResultsView
);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#course-content');
this.$searchResults = $('#courseware-search-results');
});
afterEach(function () {
this.server.restore();
});
it('shows loading message on search', showsLoadingMessage);
it('performs search', performsSearch);
it('updates navigation history', updatesNavigationHistory);
it('cancels search request', cancelsSearchRequest);
it('clears results', clearsResults);
it('loads next page', loadsNextPage);
it('navigates to search', navigatesToSearch);
});
describe('DashSearchApp', function () {
beforeEach(function () {
loadFixtures('js/fixtures/search/dashboard_search_form.html');
appendSetFixtures(
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
);
loadTemplates.call(this);
this.server = Sinon.fakeServer.create();
this.app = new DashSearchApp(
SearchRouter,
DashSearchForm,
SearchCollection,
DashSearchResultsView
);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#my-courses');
this.$searchResults = $('#dashboard-search-results');
});
afterEach(function () {
this.server.restore();
});
it('shows loading message on search', showsLoadingMessage);
it('performs search', performsSearch);
it('updates navigation history', updatesNavigationHistory);
it('cancels search request', cancelsSearchRequest);
it('clears results', clearsResults);
it('loads next page', loadsNextPage);
it('navigates to search', navigatesToSearch);
it('returns to course list', function () {
$('.search-field').val('search string');
$('.search-button').trigger('click');
this.server.respondWith([200, {}, JSON.stringify({
total: 1337,
access_denied_count: 12,
results: [{
data: {
location: ['section', 'subsection', 'unit'],
url: '/some/url/to/content',
content_type: 'text',
excerpt: 'this is a short excerpt',
course_name: ''
}
}]
})]);
this.server.respond();
expect($('.search-back-to-courses')).toExist();
$('.search-back-to-courses').trigger('click');
expect(this.$contentElement).toBeVisible();
expect(this.$searchResults).toBeHidden();
expect(this.$searchResults).toBeEmpty();
});
});
});