Skip to content

Commit

Permalink
Implement learner roster view.
Browse files Browse the repository at this point in the history
AN-6205
  • Loading branch information
dan-f committed Feb 12, 2016
1 parent 1b79fa8 commit e1fd698
Show file tree
Hide file tree
Showing 34 changed files with 1,145 additions and 53 deletions.
12 changes: 8 additions & 4 deletions analytics_dashboard/courses/templates/courses/learners.html
Expand Up @@ -7,10 +7,16 @@
View of individual learners within a course.
{% endcomment %}

{% block stylesheets %}
{{ block.super }}
<link rel="stylesheet" href="/static/bower_components/backgrid-paginator/backgrid-paginator.min.css" type="text/css">
<link rel="stylesheet" href="/static/bower_components/backgrid-filter/backgrid-filter.min.css" type="text/css">
{% endblock stylesheets %}

{% block uncompressed_javascript %}
{{ block.super }}
<script>
require(['load/init-page', 'js/learners-app'], function (page, LearnersApp) {
require(['load/init-page', 'learners/js/app'], function (page, LearnersApp) {
var app = new LearnersApp({
courseId: '{{ course_id }}',
containerSelector: '.learners-app-container',
Expand All @@ -23,7 +29,5 @@
{% endblock uncompressed_javascript %}

{% block child_content %}
<div class="learners-app-container">
<p>TODO: put the app here!</p>
</div>
<div class="learners-app-container"></div>
{% endblock %}
1 change: 1 addition & 0 deletions analytics_dashboard/courses/views/learners.py
Expand Up @@ -11,6 +11,7 @@ class LearnersView(CourseTemplateWithNavView):
template_name = 'courses/learners.html'
active_primary_nav_item = 'learners'
page_title = _('Learners')
page_name = 'learners'

def get_context_data(self, **kwargs):
context = super(LearnersView, self).get_context_data(**kwargs)
Expand Down
4 changes: 4 additions & 0 deletions analytics_dashboard/static/apps/README.md
@@ -0,0 +1,4 @@
apps directory
==============

This directory is for organizing static assets in a per-application basis.
@@ -1,13 +1,25 @@
define([
'collections/learner-collection',
'backbone',
'jquery',
'marionette',
'models/learner-model',
'underscore'
], function (LearnerCollection, $, Marionette, LearnerModel, _) {
'learners/js/collections/learner-collection',
'learners/js/controller',
'learners/js/router',
'learners/js/views/root-view',
'marionette'
], function (
Backbone,
$,
LearnerCollection,
LearnersController,
LearnersRouter,
LearnersRootView,
Marionette
) {
'use strict';

var LearnersApp = Marionette.Application.extend({
var LearnersApp;

LearnersApp = Marionette.Application.extend({
/**
* Initializes the learner analytics app.
*
Expand All @@ -27,6 +39,7 @@ define([
},

onBeforeStart: function () {
// Initialize the collection, and refresh it if necessary.
this.learnerCollection = new LearnerCollection(this.options.learnerListJson, {
url: this.options.learnerListUrl,
courseId: this.options.courseId,
Expand All @@ -38,22 +51,18 @@ define([
},

onStart: function () {
// TODO: remove this temporary UI with AN-6205.
var LearnerView = Marionette.ItemView.extend({
template: _.template(
'<div>' +
'| <%- name %> | ' +
'<%- username %> |' +
'</div>'
)
}), LearnersView = Marionette.CollectionView.extend({
childView: LearnerView
var rootView = new LearnersRootView({el: $(this.options.containerSelector)}).render(),
router;
// Initialize our router and start keeping track of history
router = new LearnersRouter({
controller: new LearnersController({
learnerCollection: this.learnerCollection,
rootView: rootView
}),
learnerCollection: this.learnerCollection
});

new LearnersView({
collection: this.learnerCollection,
el: $(this.options.containerSelector)
}).render();
Backbone.history.start();
}
});

Expand Down
@@ -1,6 +1,6 @@
define([
'components/pagination/collections/paging_collection',
'models/learner-model'
'learners/js/models/learner-model'
], function (PagingCollection, LearnerModel) {
'use strict';

Expand All @@ -17,7 +17,7 @@ define([
this.registerSortableField('problems_attempted', gettext('Problems Attempted'));
this.registerSortableField('problems_completed', gettext('Problems Completed'));
this.registerSortableField('videos_viewed', gettext('Videos Viewed'));
this.registerSortableField('problems_attempted_per_completed', gettext('Problems Attempted per Completed'));
this.registerSortableField('problem_attempts_per_completed', gettext('Problem Attempts per Completed'));
this.registerSortableField('discussion_contributions', gettext('Discussion Contributions'));

this.registerFilterableField('segments', gettext('Segments'));
Expand All @@ -29,13 +29,7 @@ define([
fetch: function (options) {
// Handle gateway timeouts
return PagingCollection.prototype.fetch.call(this, options).fail(function (jqXHR) {
// Note that we're currently only handling gateway
// timeouts here, but we can eventually check against
// other expected errors and trigger events
// accordingly.
if (jqXHR.status === 504) {
this.trigger('gatewayTimeout');
}
this.trigger('serverError', jqXHR.status, jqXHR.responseJson);
}.bind(this));
},

Expand All @@ -45,6 +39,17 @@ define([

queryParams: {
course_id: function () { return this.courseId; }
},

// Shim code follows for backgrid.paginator 0.3.5
// compatibility, which expects the backbone.pageable
// (pre-backbone.paginator) API.
hasPrevious: function () {
return this.hasPreviousPage();
},

hasNext: function () {
return this.hasNextPage();
}
});

Expand Down
46 changes: 46 additions & 0 deletions analytics_dashboard/static/apps/learners/js/controller.js
@@ -0,0 +1,46 @@
/**
* Controller object for the learners application. Handles business
* logic of showing different 'pages' of the application.
*
* Requires the following values in the options hash:
* - learnerCollection: A `LearnerCollection` instance.
* - rootView: A `LearnersRootView` instance.
*/
define([
'backbone',
'marionette',
'learners/js/views/roster-view'
], function (Backbone, Marionette, LearnerRosterView) {
'use strict';

var LearnersController = Marionette.Object.extend({
initialize: function (options) {
this.options = options || {};
},

showLearnerRosterPage: function () {
this.options.rootView.showChildView('main', new LearnerRosterView({
collection: this.options.learnerCollection
}));
},

showLearnerDetailPage: function (username) {
// TODO: we'll eventually have to fetch the learner either
// from the cached collection, or from the server. See
// https://openedx.atlassian.net/browse/AN-6191
this.options.rootView.showChildView('main', new (Backbone.View.extend({
render: function () {this.$el.text(username); return this;}
}))());
},

showNotFoundPage: function () {
// TODO: Implement this page in https://openedx.atlassian.net/browse/AN-6697
var message = gettext("Sorry, we couldn't find the page you're looking for."); // jshint ignore:line
this.options.rootView.showChildView('main', new (Backbone.View.extend({
render: function () {this.$el.text(message); return this;}
}))());
}
});

return LearnersController;
});
14 changes: 14 additions & 0 deletions analytics_dashboard/static/apps/learners/js/router.js
@@ -0,0 +1,14 @@
define(['marionette'], function (Marionette) {
'use strict';

var LearnersRouter = Marionette.AppRouter.extend({
appRoutes: {
// TODO: handle 'queryString' arguments in https://openedx.atlassian.net/browse/AN-6668
'(/)(?*queryString)': 'showLearnerRosterPage',
':username(/)(?*queryString)': 'showLearnerDetailPage',
'*notFound': 'showNotFoundPage'
}
});

return LearnersRouter;
});
@@ -0,0 +1,57 @@
define([
'jquery',
'learners/js/collections/learner-collection',
'learners/js/controller',
'marionette'
], function ($, LearnerCollection, LearnersController, Marionette) {
'use strict';

describe('LearnersController', function () {
beforeEach(function () {
var collection;
setFixtures('<div class="root-view"><div class="main"></div></div>');
this.rootView = new (Marionette.LayoutView.extend({
regions: {
main: '.main'
}
}))({el: '.root-view'});
// The learner roster view looks at the first learner in
// the collection in order to render a last updated
// message.
collection = new LearnerCollection([
{
name: 'learner',
username: 'learner',
email: 'learner@example.com',
account_url: 'example.com/learner',
enrollment_mode: 'audit',
enrollment_date: new Date(),
cohort: null,
segments: ['highly_engaged'],
engagements: {},
last_updated: new Date(),
course_id: 'test/course/id'
}
]);
this.controller = new LearnersController({rootView: this.rootView, learnerCollection: collection});
});

it('should show the learner roster page', function () {
this.controller.showLearnerRosterPage();
expect(this.rootView.$('.learner-roster')).toBeInDOM();
});

it('should show the learner detail page', function () {
// The current learner detail page is a stub. The actual
// page will be implemented in
// https://openedx.atlassian.net/browse/AN-6191
this.controller.showLearnerDetailPage('example-username');
expect(this.rootView.$el.html()).toContainText('example-username');
});

it('should show the not found page', function () {
this.controller.showNotFoundPage();
expect(this.rootView.$el.html()).toContainText('Sorry, we couldn\'t find the page you\'re looking for.');
});
});
});
@@ -1,4 +1,4 @@
require(['collections/learner-collection', 'URI'], function (LearnerCollection, URI) {
define(['learners/js/collections/learner-collection', 'URI'], function (LearnerCollection, URI) {
'use strict';

describe('LearnerCollection', function () {
Expand Down Expand Up @@ -156,10 +156,34 @@ require(['collections/learner-collection', 'URI'], function (LearnerCollection,
it('triggers an event when server gateway timeouts occur', function () {
var spy = {eventCallback: function () {}};
spyOn(spy, 'eventCallback');
learners.on('gatewayTimeout', spy.eventCallback);
learners.on('serverError', spy.eventCallback);
learners.fetch();
lastRequest().respond(504, {}, '');
expect(spy.eventCallback).toHaveBeenCalled();
});

describe('Backgrid Paginator shim', function () {
it('implements hasPrevious', function () {
learners = new LearnerCollection({
num_pages: 2, count: 50, results: []
}, {state: {currentPage: 2}, url: '/endpoint/', courseId: courseId, parse: true});
expect(learners.hasPreviousPage()).toBe(true);
expect(learners.hasPrevious()).toBe(true);
learners.state.currentPage = 1;
expect(learners.hasPreviousPage()).toBe(false);
expect(learners.hasPrevious()).toBe(false);
});

it('implements hasNext', function () {
learners = new LearnerCollection({
num_pages: 2, count: 50, results: []
}, {state: {currentPage: 1}, url: '/endpoint/', courseId: courseId, parse: true});
expect(learners.hasNextPage()).toBe(true);
expect(learners.hasNext()).toBe(true);
learners.state.currentPage = 2;
expect(learners.hasNextPage()).toBe(false);
expect(learners.hasNext()).toBe(false);
});
});
});
});
@@ -1,4 +1,4 @@
require(['backbone', 'models/learner-model'], function (Backbone, LearnerModel) {
define(['backbone', 'learners/js/models/learner-model'], function (Backbone, LearnerModel) {
'use strict';

describe('LearnerModel', function () {
Expand Down
32 changes: 32 additions & 0 deletions analytics_dashboard/static/apps/learners/js/spec/root-view-spec.js
@@ -0,0 +1,32 @@
define([
'jquery',
'learners/js/views/root-view',
'marionette'
], function ($, LearnersRootView, Marionette) {
'use strict';

describe('LearnersRootView', function () {
beforeEach(function () {
setFixtures('<div class=root-view-container></div>');
this.rootView = new LearnersRootView({el: '.root-view-container'}).render();
});

it('renders a main region', function () {
this.rootView.showChildView('main', new (Backbone.View.extend({
render: function () {
this.$el.html('example view');
}
}))());
expect(this.rootView.$('.learners-main-region').html()).toContainText('example view');
});

it('renders and clears errors', function () {
var childView = new Marionette.View();
this.rootView.showChildView('main', childView);
childView.triggerMethod('appError', 'This is the error copy');
expect(this.rootView.$('.learners-error-region')).toHaveText('This is the error copy');
this.rootView.triggerMethod('clearError', 'This is the error copy');
expect(this.rootView.$('.learners-error-region')).not.toHaveText('This is the error copy');
});
});
});

0 comments on commit e1fd698

Please sign in to comment.