Commit e75e489c by Tyler Hallada Committed by GitHub

Merge pull request #507 from edx/thallada/deep-linking

Deep linking for the Learner Roster page
parents 1324b80b 0ec62786
......@@ -17,8 +17,11 @@ define(function (require) {
LearnerDetailView = require('learners/detail/views/learner-detail'),
LearnerModel = require('learners/common/models/learner'),
LearnerRosterView = require('learners/roster/views/roster'),
LoadingView = require('learners/common/views/loading-view'),
ReturnLinkView = require('learners/detail/views/learner-return'),
rosterLoadingTemplate = require('text!learners/roster/templates/roster-loading.underscore'),
LearnersController;
LearnersController = Marionette.Object.extend({
......@@ -31,7 +34,7 @@ define(function (require) {
/**
* Event handler for the 'showPage' event. Called by the
* router whenever a route method beginning with "show" has
* been triggered.
* been triggered. Executes before the route method does.
*/
onShowPage: function () {
// Show a loading bar
......@@ -48,18 +51,53 @@ define(function (require) {
}
},
showLearnerRosterPage: function () {
showLearnerRosterPage: function (queryString) {
var rosterView = new LearnerRosterView({
collection: this.options.learnerCollection,
courseMetadata: this.options.courseMetadata,
trackingModel: this.options.trackingModel
});
collection: this.options.learnerCollection,
courseMetadata: this.options.courseMetadata,
trackingModel: this.options.trackingModel,
}),
loadingView = new LoadingView({
model: this.options.learnerCollection,
template: _.template(rosterLoadingTemplate),
successView: rosterView
}),
fetch;
try {
this.options.learnerCollection.setStateFromQueryString(queryString);
if (this.options.learnerCollection.isStale) {
// Show a loading spinner while we fetch new collection data
this.options.rootView.showChildView('main', loadingView);
fetch = this.options.learnerCollection.fetch({reset: true});
if (fetch) {
fetch.complete(function (response) {
// fetch doesn't empty the collection on 404 by default
if (response && response.status === 404) {
this.options.learnerCollection.reset();
}
});
}
} else {
// Immediately show the roster with the currently loaded collection data
this.options.rootView.showChildView('main', rosterView);
}
} catch (e) {
// These JS errors occur when trying to parse invalid URL parameters
if (e instanceof RangeError || e instanceof TypeError) {
this.options.rootView.showAlert('error', gettext('Invalid Parameters'),
gettext('Sorry, we couldn\'t find any learners who matched that query.'),
{url: '#', text: gettext('Return to the Learners page.')});
} else {
throw e;
}
}
this.options.rootView.getRegion('navigation').empty();
this.options.pageModel.set('title', gettext('Learners'));
this.onLearnerCollectionUpdated(this.options.learnerCollection);
this.options.rootView.showChildView('main', rosterView);
// track the "page" view
this.options.trackingModel.set('page', 'learner_roster');
......@@ -73,7 +111,9 @@ define(function (require) {
* succeeds.
*/
showLearnerDetailPage: function (username) {
this.options.rootView.showChildView('navigation', new ReturnLinkView({}));
this.options.rootView.showChildView('navigation', new ReturnLinkView({
queryString: this.options.learnerCollection.getQueryString()
}));
var engagementTimelineModel = new EngagementTimelineModel({}, {
url: this.options.learnerEngagementTimelineUrl.replace('temporary_username', username),
courseId: this.options.courseId
......@@ -129,7 +169,6 @@ define(function (require) {
// track the "page" view
this.options.trackingModel.set('page', 'learner_not_found');
this.options.trackingModel.trigger('segment:page');
}
});
......
......@@ -9,16 +9,32 @@ define(function (require) {
// Routes intended to show a page in the app should map to method names
// beginning with "show", e.g. 'showLearnerRosterPage'.
appRoutes: {
// TODO: handle 'queryString' arguments in https://openedx.atlassian.net/browse/AN-6668
'(/)(?*queryString)': 'showLearnerRosterPage',
':username(/)(?*queryString)': 'showLearnerDetailPage',
'*notFound': 'showNotFoundPage'
},
onRoute: function (routeName) {
if (routeName.indexOf('show') === 0) {
// This method is run before the route methods are run.
execute: function (callback, args, name) {
if (name.indexOf('show') === 0) {
this.options.controller.triggerMethod('showPage');
}
if (callback) {
callback.apply(this, args);
}
},
initialize: function (options) {
this.options = options || {};
this.learnerCollection = options.controller.options.learnerCollection;
this.listenTo(this.learnerCollection, 'sync', this.updateUrl);
Marionette.AppRouter.prototype.initialize.call(this, options);
},
// Called on LearnerCollection update. Converts the state of the collection (including any filters, searchers,
// sorts, or page numbers) into a url and then navigates the router to that url.
updateUrl: function () {
this.navigate(this.learnerCollection.getQueryString(), {replace: true});
}
});
......
......@@ -69,7 +69,7 @@ define(function (require) {
last_updated: new Date(2016, 1, 28),
course_id: courseId
};
this.collection = new LearnerCollection([this.user], {parse: true});
this.collection = new LearnerCollection([this.user], {parse: true, url:'http://example.com'});
this.controller = new LearnersController({
rootView: this.rootView,
learnerCollection: this.collection,
......@@ -92,6 +92,26 @@ define(function (require) {
expectRosterPage(this.controller);
});
it('should show the filtered learner roster page', function (done) {
this.controller.showLearnerRosterPage('text_search=foo');
expect(this.controller.options.learnerCollection.getSearchString()).toEqual('foo');
this.controller.options.learnerCollection.once('sync', function () {
expectRosterPage(this.controller);
expect(this.controller.options.rootView.$('.learners-active-filters').html()).toContainText('foo');
done();
}, this, done);
expect(this.controller.options.rootView.$('.learners-main-region').html()).toContainText('Loading...');
server.requests[server.requests.length - 1].respond(200, {}, '{}');
});
it('should show invalid parameters alert with invalid URL parameters', function () {
this.controller.showLearnerRosterPage('text_search=foo=');
expect(this.controller.options.rootView.$('.learners-alert-region').html()).toContainText(
'Invalid Parameters'
);
expect(this.controller.options.rootView.$('.learners-main-region').html()).toBe('');
});
describe('navigating to the Learner Detail page', function () {
it('should show the learner detail page', function () {
var engagementTimelineResponse;
......@@ -120,6 +140,16 @@ define(function (require) {
server.requests[server.requests.length - 1].respond(500, {}, '');
});
it('should have query string in return to learners navigation link', function () {
this.collection.state.currentPage = 2;
this.collection.setSearchString('foobar');
this.controller.showLearnerDetailPage('learner');
server.requests[0].respond(200, {}, JSON.stringify(this.user));
server.requests[server.requests.length - 1].respond(200, {}, JSON.stringify({}));
expect(this.controller.options.rootView.$('.learners-navigation-region a').attr('href'))
.toEqual('#?text_search=foobar&page=2');
});
});
// The 'showPage' event gets fired by the router on the
......
......@@ -2,23 +2,27 @@ define(function (require) {
'use strict';
var Backbone = require('backbone'),
Marionette = require('marionette'),
LearnersRouter = require('learners/app/router');
LearnerCollection = require('learners/common/collections/learners'),
LearnersController = require('learners/app/controller'),
LearnersRouter = require('learners/app/router'),
PageModel = require('learners/common/models/page');
describe('LearnersRouter', function () {
beforeEach(function () {
Backbone.history.start({silent: true});
this.controller = new (Marionette.Object.extend({
showLearnerRosterPage: function () {},
showLearnerDetailPage: function () {},
showNotFoundPage: function () {},
onShowPage: function () {}
}))();
spyOn(this.controller, 'showLearnerRosterPage');
spyOn(this.controller, 'showLearnerDetailPage');
spyOn(this.controller, 'showNotFoundPage');
spyOn(this.controller, 'onShowPage');
this.server = sinon.fakeServer.create();
this.user = {
last_updated: new Date(2016, 1, 28)
};
this.collection = new LearnerCollection([this.user], {url: 'http://example.com'});
this.controller = new LearnersController({
learnerCollection: this.collection,
pageModel: new PageModel()
});
spyOn(this.controller, 'showLearnerRosterPage').and.stub();
spyOn(this.controller, 'showLearnerDetailPage').and.stub();
spyOn(this.controller, 'showNotFoundPage').and.stub();
spyOn(this.controller, 'onShowPage').and.stub();
this.router = new LearnersRouter({
controller: this.controller
});
......@@ -28,6 +32,7 @@ define(function (require) {
// Clear previous route
this.router.navigate('');
Backbone.history.stop();
this.server.restore();
});
it('triggers a showPage event for pages beginning with "show"', function () {
......@@ -86,5 +91,16 @@ define(function (require) {
expect(this.controller.showNotFoundPage).toHaveBeenCalledWith('this/does/not/match', null);
});
});
it('URL fragment is updated on LearnerCollection sync', function (done) {
this.collection.state.currentPage = 2;
this.collection.setFilterField('text_search', 'foo');
this.collection.fetch({reset: true});
this.collection.once('sync', function () {
expect(Backbone.history.getFragment()).toBe('?text_search=foo&page=2');
done();
});
this.server.requests[0].respond(200, {}, JSON.stringify([this.user]));
});
});
});
......@@ -63,12 +63,15 @@ define(function (require) {
* types are defined in the AlertView module.
* @param {string} title - the title of the alert.
* @param {string} description - the description of the alert.
* @param {object} link - the link for the alert. Has key "url"
* (the href) and key "text" (the display text for the link).
*/
showAlert: function (type, title, description) {
showAlert: function (type, title, description, link) {
this.showChildView('alert', new AlertView({
alertType: type,
title: title,
body: description
body: description,
link: link
}));
},
......
......@@ -5,6 +5,7 @@ define(function (require) {
LearnerModel = require('learners/common/models/learner'),
LearnerUtils = require('learners/common/utils'),
Utils = require('utils/utils'),
LearnerCollection;
......@@ -52,6 +53,87 @@ define(function (require) {
hasNext: function () {
return this.hasNextPage();
},
/**
* The following two methods encode and decode the state of the collection to a query string. This query string
* is different than queryParams, which we send to the API server during a fetch. Here, the string encodes the
* current user view on the collection including page number, filters applied, search query, and sorting. The
* string is then appended on to the fragment identifier portion of the URL.
*
* e.g. http://.../learners/#?text_search=foo&sortKey=username&order=desc&page=1
*/
// Encodes the state of the collection into a query string that can be appended onto the URL.
getQueryString: function () {
var params = this.getActiveFilterFields(true),
orderedParams = [],
fragment;
// Order the parameters: filters & search, sortKey, order, and then page.
// Because the active filter fields object is not ordered, these are the only params of orderedParams that
// don't have a defined order besides being before sortKey, order, and page.
_.mapObject(params, function (val, key) {
orderedParams.push({key: key, val: val});
});
if (this.state.sortKey !== null) {
orderedParams.push({key: 'sortKey', val: this.state.sortKey});
orderedParams.push({key: 'order', val: this.state.order === 1 ? 'desc' : 'asc'});
}
orderedParams.push({key: 'page', val: this.state.currentPage});
fragment = orderedParams.map(function (param) {
// Note: this assumes that filter keys will never have URI special characters. We should encode the key
// too if that assumption is wrong.
return param.key + '=' + encodeURIComponent(param.val);
}).join('&');
// Prefix query string params with '?', but return an empty string if there are no params.
return fragment !== '' ? ('?' + fragment) : fragment;
},
/**
* Decodes a query string into arguments and sets the state of the collection to what the arguments describe.
* The query string argument should have already had the prefix '?' stripped (the AppRouter does this).
*
* Will set the collection's isStale boolean to whether the new state differs from the old state (so the caller
* knows that the collection is stale and needs to do a fetch).
*/
setStateFromQueryString: function (queryString) {
var params = Utils.parseQueryString(queryString),
order = -1,
page, sortKey;
_.mapObject(params, function (val, key) {
if (key === 'page') {
page = parseInt(val, 10);
if (page !== this.state.currentPage) {
this.isStale = true;
}
this.state.currentPage = page;
} else if (key === 'sortKey') {
sortKey = val;
} else if (key === 'order') {
order = val === 'desc' ? 1 : -1;
} else {
if (key in this.filterableFields || key === 'text_search') {
if (val !== this.getFilterFieldValue(key)) {
this.isStale = true;
}
this.setFilterField(key, val);
}
}
}, this);
// Set the sort state if sortKey or order from the queryString are different from the current state
if (sortKey && sortKey in this.sortableFields) {
if (sortKey !== this.state.sortKey || order !== this.state.order) {
this.isStale = true;
this.setSorting(sortKey, order);
}
}
}
});
......
......@@ -189,5 +189,77 @@ define(function (require) {
expect(learners.hasNext()).toBe(false);
});
});
describe('Encoding State to a Query String', function () {
it('encodes an empty state', function () {
expect(learners.getQueryString()).toBe('?page=1');
});
it('encodes the page number', function () {
learners.state.currentPage = 2;
expect(learners.getQueryString()).toBe('?page=2');
});
it('encodes the sort state', function () {
learners.state.sortKey = 'username';
learners.state.order = 1;
expect(learners.getQueryString()).toBe('?sortKey=username&order=desc&page=1');
});
it('encodes the text search', function () {
learners.setFilterField('text_search', 'foo');
expect(learners.getQueryString()).toBe('?text_search=foo&page=1');
});
it('encodes the filters', function () {
learners.setFilterField('enrollment_mode', 'audit');
learners.setFilterField('cohort', 'group1');
// order of filter fields in query string is not defined
var qstring = learners.getQueryString(),
pageAfterFilters = (qstring === '?enrollment_mode=audit&cohort=group1&page=1' ||
qstring === '?cohort=group1&enrollment_mode=audit&page=1');
expect(pageAfterFilters).toBe(true);
});
});
describe('Decoding Query String to a State', function () {
var state = {};
beforeEach(function () {
state = {
firstPage: 1,
lastPage: null,
currentPage: 1,
pageSize: 25,
totalPages: null,
totalRecords: null,
sortKey: null,
order: -1
};
});
it('decodes an empty query string', function () {
learners.setStateFromQueryString('');
expect(learners.state).toEqual(state);
expect(learners.getActiveFilterFields()).toEqual({});
});
it('decodes the page number', function () {
state.currentPage = 2;
learners.setStateFromQueryString('page=2');
expect(learners.state).toEqual(state);
expect(learners.getActiveFilterFields()).toEqual({});
});
it('decodes the sort', function () {
state.sortKey = 'username';
state.order = 1;
learners.setStateFromQueryString('sortKey=username&order=desc');
expect(learners.state).toEqual(state);
expect(learners.getActiveFilterFields()).toEqual({});
});
it('decodes the text search', function () {
learners.setStateFromQueryString('text_search=foo');
expect(learners.state).toEqual(state);
expect(learners.getSearchString()).toEqual('foo');
});
it('decodes the filters', function () {
learners.setStateFromQueryString('enrollment_mode=audit&cohort=group1');
expect(learners.state).toEqual(state);
expect(learners.getActiveFilterFields()).toEqual({enrollment_mode: 'audit', cohort: 'group1'});
});
});
});
});
......@@ -9,16 +9,23 @@
<%- title %>
</div>
<% if (body) { %>
<div>
<div class="alert-body">
<%- body %>
</div>
<% } %>
<% if (suggestions.length) { %>
<ul class="suggestions">
<% suggestions.map(function (suggestion) { %>
<li><%- suggestion %></li>
<% }); %>
</ul>
<div>
<ul class="suggestions">
<% suggestions.map(function (suggestion) { %>
<li><%- suggestion %></li>
<% }); %>
</ul>
</div>
<% } %>
<% if (link) { %>
<div class="link">
<a href="<%- link.url %>"><%- link.text %></a>
</div>
<% } %>
</div>
</div>
......
......@@ -31,7 +31,8 @@ define(function (require) {
alertType: 'info', // default alert type
title: undefined, // string title of alert
body: undefined, // string body of alert
suggestions: [] // list of strings to display after the body
suggestions: [], // list of strings to display after the body
link: undefined, // string to display and url of link on alert
},
/**
......
......@@ -41,5 +41,24 @@ define(function (require) {
expect(fixture).toContainElement('li');
});
it('renders an alert with a link', function () {
var fixture = setFixtures('<div class="info-alert"></div>');
new AlertView({
el: '.info-alert',
alertType: 'info',
title: 'edX Info Alert',
body: 'More description about an information alert.',
suggestions: ['Display alerts.', 'Alerts are helpful messages!'],
link: {url: 'http://example.com', text: 'A helpful link.'}
}).render();
expect(fixture).toContainElement('i.fa-bullhorn');
expect(fixture).toContainElement('.alert-info-container');
expect(fixture).toContainElement('li');
expect(fixture).toContainElement('.link');
expect(fixture).toContainElement('a');
});
});
});
<a href="">
<a href="#<%- queryString %>">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
<%- returnText %>
</a>
......@@ -9,7 +9,8 @@ define(function (require) {
templateHelpers: function() {
return {
returnText : gettext('Return to Learners')
returnText : gettext('Return to Learners'),
queryString: this.options.queryString
};
},
});
......
<div class="loading">
<p class="text-center"><i class="fa fa-spinner fa-spin fa-lg" aria-hidden="true"></i> <%- loadingText %></p>
</div>
......@@ -37,6 +37,11 @@ define(function (require) {
},
render: function (column, direction) {
if (this.collection.state.sortKey && this.collection.state.sortKey === this.column.attributes.name) {
direction = this.collection.state.order ? 'ascending' : 'descending';
this.column.attributes.direction = direction;
column = this.column;
}
Backgrid.HeaderCell.prototype.render.apply(this, arguments);
this.$el.html(this.template({
label: this.column.get('label')
......
......@@ -86,4 +86,18 @@ define(['utils/utils'], function (Utils) {
});
});
describe('parseQueryString', function () {
it('should parse query string', function () {
expect(Utils.parseQueryString('foo=bar&baz=quux')).toEqual({foo: 'bar', baz: 'quux'});
expect(Utils.parseQueryString('foo=bar&')).toEqual({foo: 'bar'});
expect(Utils.parseQueryString('foo=bar&baz')).toEqual({foo: 'bar', baz: ''});
expect(Utils.parseQueryString('foo=bar&baz=')).toEqual({foo: 'bar', baz: ''});
expect(Utils.parseQueryString('')).toEqual({});
expect(Utils.parseQueryString(null)).toEqual({});
expect(function () { Utils.parseQueryString('foo=bar='); }).toThrow(
new Error('Each "&"-separated substring must either be a key or a key-value pair')
);
});
});
});
......@@ -103,6 +103,41 @@ define(['moment', 'underscore', 'utils/globalization'], function (moment, _, Glo
}
return time;
}).join(':');
},
/**
* Converts the querystring portion of the URL into an object
* mapping keys to argument values.
*
* Examples:
* - 'foo=bar&baz=quux' -> {foo: 'bar', baz: 'quux'}
* - 'foo=bar&' -> {foo: 'bar'}
* - 'foo=bar&baz' -> {foo: 'bar', baz: ''}
* - 'foo=bar&baz=' -> {foo: 'bar', baz: ''}
* - '' -> {}
* - null -> {}
*
* @param queryString {string}
* @returns {object}
*/
parseQueryString: function (queryString) {
if (queryString) {
return _(decodeURI(queryString).split('&')).map(function (namedVal) {
var keyValPair = namedVal.split('='), params = {};
if (keyValPair.length === 1 && keyValPair[0]) { // No value
params[keyValPair[0]] = '';
} else if (keyValPair.length === 2){
params[keyValPair[0]] = keyValPair[1];
} else if (keyValPair.length > 2) { // Have something like foo=bar=...
throw new TypeError('Each "&"-separated substring must either be a key or a key-value pair');
}
return params;
}).reduce(function (memo, keyValPair) {
return _.extend(memo, keyValPair);
});
} else {
return {};
}
}
};
......
......@@ -38,7 +38,7 @@
@mixin alert-container($alert-color) {
padding: $padding-large-horizontal $padding-large-vertical;
padding-bottom: $padding-large-horizontal * 2;
padding-bottom: ($padding-large-horizontal * 2) - $padding-small-horizontal;
margin-bottom: $padding-large-horizontal * 2;
background-color: $alert-background-color;
border-top: 4px solid $alert-color;
......@@ -55,6 +55,10 @@
padding-bottom: $padding-small-horizontal;
}
.alert-body, .link {
padding-bottom: $padding-small-horizontal;
}
// this keeps the gray borders from touching the top band at a 45 degree angle
&:before {
border: $description-border;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment