Commit 3a0853c5 by Daniel Friedman

Implement cohort filter dropdown

AN-6235
parent d66c9256
...@@ -142,6 +142,7 @@ ...@@ -142,6 +142,7 @@
"spyOnEvent", "spyOnEvent",
// Django translation // Django translation
"gettext" "gettext",
"ngettext"
] ]
} }
...@@ -37,8 +37,8 @@ class CourseStructureApiClient(clients.CourseStructureApiClient): ...@@ -37,8 +37,8 @@ class CourseStructureApiClient(clients.CourseStructureApiClient):
def feature_flagged(feature_flag): def feature_flagged(feature_flag):
""" """
A decorator for class-based views which should throw 404s when a A decorator for class-based views which throws 404s when a waffle
waffle flag is not enabled. flag is not enabled.
""" """
def decorator(cls): def decorator(cls):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
......
...@@ -20,7 +20,9 @@ View of individual learners within a course. ...@@ -20,7 +20,9 @@ View of individual learners within a course.
courseId: '{{ course_id }}', courseId: '{{ course_id }}',
containerSelector: '.learners-app-container', containerSelector: '.learners-app-container',
learnerListJson: {{ learner_list_json | escape_json }}, learnerListJson: {{ learner_list_json | escape_json }},
learnerListUrl: '{{ learner_list_url }}' learnerListUrl: '{{ learner_list_url }}',
courseLearnerMetadataJson: {{ course_learner_metadata_json | escape_json }},
courseLearnerMetadataUrl: '{{ course_learner_metadata_url }}'
}); });
app.start(); app.start();
}); });
......
import json
import logging
from urllib import quote_plus
from ddt import data, ddt
import httpretty
import mock
from requests.exceptions import ConnectionError, Timeout
from testfixtures import LogCapture
from django.conf import settings
from django.test import TestCase
from courses.tests import SwitchMixin
from courses.tests.test_views import DEMO_COURSE_ID, ViewTestMixin
@httpretty.activate
@ddt
class LearnersViewTests(ViewTestMixin, SwitchMixin, TestCase):
viewname = 'courses:learners:learners'
@classmethod
def setUpClass(cls, *args, **kwargs):
super(LearnersViewTests, cls).setUpClass(*args, **kwargs)
cls.toggle_switch('enable_learner_analytics', True)
@classmethod
def tearDownClass(cls, *args, **kwargs):
super(LearnersViewTests, cls).tearDownClass(*args, **kwargs)
cls.toggle_switch('enable_learner_analytics', False)
def _register_uris(self, learners_status, learners_payload, course_metadata_status, course_metadata_payload):
httpretty.register_uri(
httpretty.GET,
'{data_api_url}/learners/'.format(data_api_url=settings.DATA_API_URL),
body=json.dumps(learners_payload),
status=learners_status
)
httpretty.register_uri(
httpretty.GET,
'{data_api_url}/course_learner_metadata/{course_id}/'.format(
data_api_url=settings.DATA_API_URL,
course_id=DEMO_COURSE_ID,
),
body=json.dumps(course_metadata_payload),
status=course_metadata_status
)
self.addCleanup(httpretty.reset)
def _get(self):
return self.client.get(self.path(course_id=DEMO_COURSE_ID), follow=True)
def _assert_context(self, response, expected_context_subset):
default_expected_context_subset = {
'learner_list_url': '/api/learner_analytics/v0/learners/',
'course_learner_metadata_url': '/api/learner_analytics/v0/course_learner_metadata/{course_id}/'.format(
course_id=quote_plus(DEMO_COURSE_ID)
),
}
self.assertDictContainsSubset(
dict(default_expected_context_subset.items() + expected_context_subset.items()),
response.context
)
def get_mock_data(self, *args, **kwargs):
pass
def test_feature_flag(self):
self.toggle_switch('enable_learner_analytics', False)
self.assertEqual(self._get().status_code, 404)
self.toggle_switch('enable_learner_analytics', True)
def test_success(self):
learners_payload = {'arbitrary_learners_key': ['arbitrary_value_1', 'arbitrary_value_2']}
course_metadata_payload = {'arbitrary_metadata_value': {'arbitrary_value_1': 'arbitrary_value_2'}}
self._register_uris(200, learners_payload, 200, course_metadata_payload)
response = self._get()
self._assert_context(response, {
'learner_list_json': learners_payload,
'course_learner_metadata_json': course_metadata_payload,
})
@data(Timeout, ConnectionError)
def test_data_api_error(self, RequestExceptionClass):
learners_payload = {'should_not': 'return this value'}
course_metadata_payload = learners_payload
self._register_uris(200, learners_payload, 200, course_metadata_payload)
with mock.patch(
'learner_analytics_api.v0.clients.LearnerApiResource.get',
mock.Mock(side_effect=RequestExceptionClass)
):
with LogCapture(level=logging.ERROR) as lc:
response = self._get()
self._assert_context(response, {
'learner_list_json': {},
'course_learner_metadata_json': {},
})
lc.check(
('courses.views.learners', 'ERROR', 'Failed to reach the Learner List endpoint'),
('courses.views.learners', 'ERROR', 'Failed to reach the Course Learner Metadata endpoint')
)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
from waffle import switch_is_active
from django.conf import settings from django.conf import settings
from django.conf.urls import url, patterns, include from django.conf.urls import url, patterns, include
...@@ -115,11 +113,9 @@ COURSE_URLS = patterns( ...@@ -115,11 +113,9 @@ COURSE_URLS = patterns(
url(r'^engagement/', include(ENGAGEMENT_URLS, namespace='engagement')), url(r'^engagement/', include(ENGAGEMENT_URLS, namespace='engagement')),
url(r'^performance/', include(PERFORMANCE_URLS, namespace='performance')), url(r'^performance/', include(PERFORMANCE_URLS, namespace='performance')),
url(r'^csv/', include(CSV_URLS, namespace='csv')), url(r'^csv/', include(CSV_URLS, namespace='csv')),
url(r'^learners/', include(LEARNER_URLS, namespace='learners')),
) )
if switch_is_active('enable_learner_analytics'):
COURSE_URLS += patterns('', url(r'^learners/', include(LEARNER_URLS, namespace='learners')))
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url('^$', views.CourseIndex.as_view(), name='index'), url('^$', views.CourseIndex.as_view(), name='index'),
......
from requests.exceptions import ConnectTimeout import logging
from requests.exceptions import ConnectionError, Timeout
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from core.utils import feature_flagged
from courses.views import CourseTemplateWithNavView from courses.views import CourseTemplateWithNavView
from learner_analytics_api.v0.clients import LearnerAPIClient from learner_analytics_api.v0.clients import LearnerAPIClient
logger = logging.getLogger(__name__)
@feature_flagged('enable_learner_analytics')
class LearnersView(CourseTemplateWithNavView): class LearnersView(CourseTemplateWithNavView):
template_name = 'courses/learners.html' template_name = 'courses/learners.html'
active_primary_nav_item = 'learners' active_primary_nav_item = 'learners'
...@@ -17,14 +23,31 @@ class LearnersView(CourseTemplateWithNavView): ...@@ -17,14 +23,31 @@ class LearnersView(CourseTemplateWithNavView):
context = super(LearnersView, self).get_context_data(**kwargs) context = super(LearnersView, self).get_context_data(**kwargs)
context.update({ context.update({
'page_data': self.get_page_data(context), 'page_data': self.get_page_data(context),
'learner_list_url': reverse('learner_analytics_api:v0:LearnerList') 'learner_list_url': reverse('learner_analytics_api:v0:LearnerList'),
'course_learner_metadata_url': reverse(
'learner_analytics_api:v0:CourseMetadata',
args=(self.course_id,)
),
}) })
# Try to grab the first page of learners. If the analytics # Try to prefetch API responses. If anything fails, the front-end will
# API times out, the front-end will attempt to asynchronously # retry the requests and gracefully fail.
# fetch the first page.
client = LearnerAPIClient() client = LearnerAPIClient()
try: for data_name, request_function, error_message in [
context['learner_list_json'] = client.learners.get(course_id=self.course_id).json() (
except ConnectTimeout: 'learner_list_json',
context['learner_list_json'] = None lambda: client.learners.get(course_id=self.course_id).json(),
'Failed to reach the Learner List endpoint',
),
(
'course_learner_metadata_json',
lambda: client.course_learner_metadata(self.course_id).get().json(),
'Failed to reach the Course Learner Metadata endpoint',
)
]:
try:
context[data_name] = request_function()
except (Timeout, ConnectionError):
logger.exception(error_message)
context[data_name] = {}
return context return context
...@@ -3,6 +3,7 @@ define([ ...@@ -3,6 +3,7 @@ define([
'jquery', 'jquery',
'learners/js/collections/learner-collection', 'learners/js/collections/learner-collection',
'learners/js/controller', 'learners/js/controller',
'learners/js/models/course-metadata',
'learners/js/router', 'learners/js/router',
'learners/js/views/root-view', 'learners/js/views/root-view',
'marionette' 'marionette'
...@@ -11,6 +12,7 @@ define([ ...@@ -11,6 +12,7 @@ define([
$, $,
LearnerCollection, LearnerCollection,
LearnersController, LearnersController,
CourseMetadataModel,
LearnersRouter, LearnersRouter,
LearnersRootView, LearnersRootView,
Marionette Marionette
...@@ -28,18 +30,26 @@ define([ ...@@ -28,18 +30,26 @@ define([
* learner app * learner app
* - containerSelector (string) required - the CSS selector * - containerSelector (string) required - the CSS selector
* for the HTML element that this app should attach to * for the HTML element that this app should attach to
* - learnerListUrl (string) required - the URL for the
* Learner List API endpoint.
* - courseLearnerMetadataUrl (String) required - the URL for
* the Course Learner Metadata API endpoint.
* - learnerListJson (Object) optional - an Object * - learnerListJson (Object) optional - an Object
* representing an initial server response from the Learner * representing an initial server response from the Learner
* List endpoint used for pre-populating the app's * List endpoint used for pre-populating the app's
* LearnerCollection. If not provided, the data is fetched * LearnerCollection. If not provided, the data is fetched
* asynchronously before app initialization. * asynchronously before app initialization.
* - courseLearnerMetadataJson (Object) optional - an Object
* representing an initial server response from the Learner
* Course Metadata endpoint used for data on cohorts,
* segments, enrollment modes, and engagement ranges.
*/ */
initialize: function (options) { initialize: function (options) {
this.options = options || {}; this.options = options || {};
}, },
onBeforeStart: function () { onBeforeStart: function () {
// Initialize the collection, and refresh it if necessary. // Initialize the learner collection, and refresh it if necessary.
this.learnerCollection = new LearnerCollection(this.options.learnerListJson, { this.learnerCollection = new LearnerCollection(this.options.learnerListJson, {
url: this.options.learnerListUrl, url: this.options.learnerListUrl,
courseId: this.options.courseId, courseId: this.options.courseId,
...@@ -48,6 +58,14 @@ define([ ...@@ -48,6 +58,14 @@ define([
if (!this.options.learnerListJson) { if (!this.options.learnerListJson) {
this.learnerCollection.setPage(1); this.learnerCollection.setPage(1);
} }
// Inititalize the course metadata model, and fetch it if necessary
this.courseMetadata = new CourseMetadataModel(this.options.courseLearnerMetadataJson, {
url: this.options.courseLearnerMetadataUrl,
parse: true
});
if (!this.options.courseLearnerMetadataJson) {
this.courseMetadata.fetch();
}
}, },
onStart: function () { onStart: function () {
...@@ -57,9 +75,9 @@ define([ ...@@ -57,9 +75,9 @@ define([
router = new LearnersRouter({ router = new LearnersRouter({
controller: new LearnersController({ controller: new LearnersController({
learnerCollection: this.learnerCollection, learnerCollection: this.learnerCollection,
courseMetadata: this.courseMetadata,
rootView: rootView rootView: rootView
}), }),
learnerCollection: this.learnerCollection
}); });
Backbone.history.start(); Backbone.history.start();
......
...@@ -20,7 +20,8 @@ define([ ...@@ -20,7 +20,8 @@ define([
showLearnerRosterPage: function () { showLearnerRosterPage: function () {
this.options.rootView.showChildView('main', new LearnerRosterView({ this.options.rootView.showChildView('main', new LearnerRosterView({
collection: this.options.learnerCollection collection: this.options.learnerCollection,
courseMetadata: this.options.courseMetadata
})); }));
}, },
......
define(['backbone'], function (Backbone) {
'use strict';
var CourseMetadataModel = Backbone.Model.extend({
defaults:{
cohorts: {},
segments: {},
enrollment_modes: {},
engagement_ranges: {
date_range: {},
problems_attempted: {},
problems_completed: {},
problem_attempts_per_completed: {},
discussions_contributed: {}
}
},
initialize: function (attributes, options) {
this.options = options || {};
},
url: function () {
return this.options.url;
}
});
return CourseMetadataModel;
});
...@@ -2,8 +2,9 @@ define([ ...@@ -2,8 +2,9 @@ define([
'jquery', 'jquery',
'learners/js/collections/learner-collection', 'learners/js/collections/learner-collection',
'learners/js/controller', 'learners/js/controller',
'learners/js/models/course-metadata',
'marionette' 'marionette'
], function ($, LearnerCollection, LearnersController, Marionette) { ], function ($, LearnerCollection, LearnersController, CourseMetadataModel, Marionette) {
'use strict'; 'use strict';
describe('LearnersController', function () { describe('LearnersController', function () {
...@@ -33,7 +34,11 @@ define([ ...@@ -33,7 +34,11 @@ define([
course_id: 'test/course/id' course_id: 'test/course/id'
} }
]); ]);
this.controller = new LearnersController({rootView: this.rootView, learnerCollection: collection}); this.controller = new LearnersController({
rootView: this.rootView,
learnerCollection: collection,
courseMetadata: new CourseMetadataModel()
});
}); });
it('should show the learner roster page', function () { it('should show the learner roster page', function () {
......
require(['learners/js/models/course-metadata'], function (CourseMetadataModel) {
'use strict';
describe('CourseMetadataModel', function () {
it('sets its url through the initialize function', function () {
var url = '/resource/';
expect(new CourseMetadataModel(null, {url: url}).url()).toBe(url);
});
});
});
...@@ -2,11 +2,12 @@ define([ ...@@ -2,11 +2,12 @@ define([
'axe-core', 'axe-core',
'jquery', 'jquery',
'learners/js/collections/learner-collection', 'learners/js/collections/learner-collection',
'learners/js/models/course-metadata',
'learners/js/models/learner-model', 'learners/js/models/learner-model',
'learners/js/views/roster-view', 'learners/js/views/roster-view',
'underscore', 'underscore',
'URI' 'URI'
], function (axe, $, LearnerCollection, LearnerModel, LearnerRosterView, _, URI) { ], function (axe, $, LearnerCollection, CourseMetadataModel, LearnerModel, LearnerRosterView, _, URI) {
'use strict'; 'use strict';
describe('LearnerRosterView', function () { describe('LearnerRosterView', function () {
...@@ -37,12 +38,14 @@ define([ ...@@ -37,12 +38,14 @@ define([
}; };
}; };
getRosterView = function (collectionResponse, collectionOptions) { getRosterView = function (options) {
options = options || {};
var rosterView = new LearnerRosterView({ var rosterView = new LearnerRosterView({
collection: new LearnerCollection( collection: new LearnerCollection(
collectionResponse, options.collectionResponse,
_.extend({url: 'test-url'}, collectionOptions) _.extend({url: 'test-url'}, options.collectionOptions)
), ),
courseMetadata: new CourseMetadataModel(options.courseMetadata),
el: '.' + fixtureClass el: '.' + fixtureClass
}).render(); }).render();
rosterView.onBeforeShow(); rosterView.onBeforeShow();
...@@ -64,8 +67,11 @@ define([ ...@@ -64,8 +67,11 @@ define([
}); });
it('displays the last updated date', function () { it('displays the last updated date', function () {
var roster = getRosterView({results: [{last_updated: new Date('1/1/2016')}]}, {parse: true}); var roster = getRosterView({
expect(roster.$('.last-updated-message')).toContainText('Date Last Updated: January 1, 2016'); collectionResponse: {results: [{last_updated: new Date('1/2/2016')}]},
collectionOptions: {parse: true}
});
expect(roster.$('.last-updated-message')).toContainText('Date Last Updated: January 2, 2016');
}); });
it('renders a list of learners', function () { it('renders a list of learners', function () {
...@@ -83,8 +89,10 @@ define([ ...@@ -83,8 +89,10 @@ define([
{name: 'lily', username: 'lily', engagements: generateEngagements()}, {name: 'lily', username: 'lily', engagements: generateEngagements()},
{name: 'zita', username: 'zita', engagements: generateEngagements()} {name: 'zita', username: 'zita', engagements: generateEngagements()}
], ],
rosterView = getRosterView({results: learners}, {parse: true}); rosterView = getRosterView({
collectionResponse: {results: learners},
collectionOptions: {parse: true}
});
_.chain(_.zip(learners, rosterView.$('tbody tr'))).each(function (learnerAndTr) { _.chain(_.zip(learners, rosterView.$('tbody tr'))).each(function (learnerAndTr) {
var learner = learnerAndTr[0], var learner = learnerAndTr[0],
tr = learnerAndTr[1]; tr = learnerAndTr[1];
...@@ -204,7 +212,10 @@ define([ ...@@ -204,7 +212,10 @@ define([
}; };
createTwoPageRoster = function () { createTwoPageRoster = function () {
return getRosterView(getResponseBody(2, 1), {parse: true}); return getRosterView({
collectionResponse: getResponseBody(2, 1),
collectionOptions: {parse: true}
});
}; };
expectLinkStates = function (rosterView, activeLinkTitle, disabledLinkTitles) { expectLinkStates = function (rosterView, activeLinkTitle, disabledLinkTitles) {
...@@ -333,6 +344,60 @@ define([ ...@@ -333,6 +344,60 @@ define([
}); });
}); });
describe('filtering', function () {
describe('by cohort', function () {
var expectCanFilterBy = function (cohort) {
$('select').val(cohort);
$('select').change();
if (cohort) {
expect(getLastRequestParams()).toEqual(jasmine.objectContaining({
cohort: cohort
}));
} else {
expect(getLastRequestParams().hasOwnProperty('cohort')).toBe(false);
}
getLastRequest().respond(200, {}, JSON.stringify(getResponseBody(1, 1)));
expect($('option[value="' + cohort + '"]')).toBeSelected();
};
it('does not render when the course contains no cohorts', function () {
var rosterView = getRosterView({courseMetadata: {cohorts: {}}});
expect(rosterView.$('.learners-cohort-filter').children()).not.toExist();
});
it('renders when the course contains cohorts', function () {
var rosterView = getRosterView({courseMetadata: {cohorts: {
'Cohort A': 1,
'Cohort B': 2
}}}),
options = rosterView.$('.learners-cohort-filter option'),
defaultOption = $(options[0]),
cohortAOption = $(options[1]),
cohortBOption = $(options[2]);
expect(defaultOption).toBeSelected();
expect(defaultOption).toHaveValue('');
expect(defaultOption).toHaveText('All');
expect(cohortAOption).not.toBeSelected();
expect(cohortAOption).toHaveValue('Cohort A');
expect(cohortAOption).toHaveText('Cohort A (1 learner)');
expect(cohortBOption).not.toBeSelected();
expect(cohortBOption).toHaveValue('Cohort B');
expect(cohortBOption).toHaveText('Cohort B (2 learners)');
});
it('can execute a cohort filter', function () {
getRosterView({courseMetadata: {cohorts: {
'Cohort A': 1
}}});
expectCanFilterBy('Cohort A');
expectCanFilterBy('');
});
});
});
describe('accessibility', function () { describe('accessibility', function () {
it('the table has a <caption> element', function () { it('the table has a <caption> element', function () {
var rosterView = getRosterView(); var rosterView = getRosterView();
...@@ -361,8 +426,9 @@ define([ ...@@ -361,8 +426,9 @@ define([
it('the search input has a label', function () { it('the search input has a label', function () {
var rosterView = getRosterView(), var rosterView = getRosterView(),
inputId = rosterView.$('input').attr('id'), searchContainer = rosterView.$('.learners-search-container'),
$label = rosterView.$('label'); inputId = searchContainer.find('input').attr('id'),
$label = searchContainer.find('label');
expect($label).toHaveAttr('for', inputId); expect($label).toHaveAttr('for', inputId);
expect($label).toHaveText('Search learners'); expect($label).toHaveText('Search learners');
}); });
...@@ -375,7 +441,10 @@ define([ ...@@ -375,7 +441,10 @@ define([
}); });
it('sets focus to the top of the table after taking a paging action', function () { it('sets focus to the top of the table after taking a paging action', function () {
var rosterView = getRosterView(getResponseBody(2, 1), {parse: true}), var rosterView = getRosterView({
collectionResponse: getResponseBody(2, 1),
collectionOptions: {parse: true}
}),
firstPageLink = rosterView.$('.backgrid-paginator li a[title="Page 1"]'), firstPageLink = rosterView.$('.backgrid-paginator li a[title="Page 1"]'),
secondPageLink = rosterView.$('.backgrid-paginator li a[title="Page 2"]'); secondPageLink = rosterView.$('.backgrid-paginator li a[title="Page 2"]');
// It would be ideal to use jasmine-jquery's // It would be ideal to use jasmine-jquery's
...@@ -396,7 +465,10 @@ define([ ...@@ -396,7 +465,10 @@ define([
}); });
it('does not violate the axe-core ruleset', function (done) { it('does not violate the axe-core ruleset', function (done) {
getRosterView(getResponseBody(1, 1), {parse: true}); getRosterView({
collectionResponse: getResponseBody(1, 1),
collectionOptions: {parse: true}
});
axe.a11yCheck($('.roster-view-fixture')[0], function (result) { axe.a11yCheck($('.roster-view-fixture')[0], function (result) {
expect(result.violations.length).toBe(0); expect(result.violations.length).toBe(0);
done(); done();
......
...@@ -13,6 +13,7 @@ define([ ...@@ -13,6 +13,7 @@ define([
'bootstrap_accessibility', // adds the aria-describedby to tooltips 'bootstrap_accessibility', // adds the aria-describedby to tooltips
'jquery', 'jquery',
'marionette', 'marionette',
'text!learners/templates/cohort-filter.underscore',
'text!learners/templates/base-header-cell.underscore', 'text!learners/templates/base-header-cell.underscore',
'text!learners/templates/name-username-cell.underscore', 'text!learners/templates/name-username-cell.underscore',
'text!learners/templates/page-handle.underscore', 'text!learners/templates/page-handle.underscore',
...@@ -28,6 +29,7 @@ define([ ...@@ -28,6 +29,7 @@ define([
_BootstrapAccessibility, _BootstrapAccessibility,
$, $,
Marionette, Marionette,
cohortFilterTemplate,
baseHeaderCellTemplate, baseHeaderCellTemplate,
nameUsernameCellTemplate, nameUsernameCellTemplate,
pageHandleTemplate, pageHandleTemplate,
...@@ -39,6 +41,7 @@ define([ ...@@ -39,6 +41,7 @@ define([
'use strict'; 'use strict';
var BaseHeaderCell, var BaseHeaderCell,
CohortFiliter,
createEngagementCell, createEngagementCell,
createEngagementHeaderCell, createEngagementHeaderCell,
LearnerSearch, LearnerSearch,
...@@ -219,7 +222,7 @@ define([ ...@@ -219,7 +222,7 @@ define([
*/ */
LearnerSearch = Backgrid.Extension.ServerSideFilter.extend({ LearnerSearch = Backgrid.Extension.ServerSideFilter.extend({
className: function () { className: function () {
return Backgrid.Extension.ServerSideFilter.prototype.className + ' ' + 'learners-search'; return [Backgrid.Extension.ServerSideFilter.prototype.className, 'learners-search'].join(' ');
}, },
events: function () { events: function () {
return _.extend(Backgrid.Extension.ServerSideFilter.prototype.events, {'click .search': 'search'}); return _.extend(Backgrid.Extension.ServerSideFilter.prototype.events, {'click .search': 'search'});
...@@ -237,6 +240,66 @@ define([ ...@@ -237,6 +240,66 @@ define([
this.showClearButtonMaybe(); this.showClearButtonMaybe();
this.delegateEvents(); this.delegateEvents();
return this; return this;
},
search: function () {
Backgrid.Extension.ServerSideFilter.prototype.search.apply(this, arguments);
this.resetFocus();
},
clear: function () {
Backgrid.Extension.ServerSideFilter.prototype.clear.apply(this, arguments);
this.resetFocus();
},
resetFocus: function () {
$('#learner-app-focusable').focus();
}
});
/**
* A component to filter the roster view by cohort.
*/
CohortFiliter = Marionette.ItemView.extend({
events: {
'change #cohort-filter': 'onSelectCohort'
},
className: 'learners-cohort-filter',
template: _.template(cohortFilterTemplate),
initialize: function (options) {
this.options = options || {};
_.bind(this.onSelectCohort, this);
},
templateHelpers: function () {
var cohorts = _.mapObject(this.options.courseMetadata.get('cohorts'), function (numLearners, cohortName) {
return {
displayName: _.template(ngettext(
// jshint ignore:start
// Translators: 'cohortName' is the name of the cohort and 'numLearners' is the number of learners in that cohort. The resulting phrase displays a cohort and the number of students belonging to it.
'<%- cohortName %> (<%- numLearners %> learner)',
// Translators: 'cohortName' is the name of the cohort and 'numLearners' is the number of learners in that cohort. The resulting phrase displays a cohort and the number of students belonging to it.
'<%- cohortName %> (<%- numLearners %> learners)',
// jshint ignore:end
numLearners
))({
cohortName: cohortName,
numLearners: Utils.localizeNumber(numLearners, 0)
})
};
});
return {
cohorts: cohorts,
// Translators: "Cohort Groups" refers to groups of students within a course.
selectDisplayName: gettext('Cohort Groups'),
// Translators: "All" refers to viewing all the learners in a course.
allCohortsSelectedMessage: gettext('All')
};
},
onSelectCohort: function (event) {
// Sends a request to the server for the learner list filtered by
// cohort then resets focus.
event.preventDefault();
this.collection.setFilterField('cohort', $(event.currentTarget).find('option:selected').val());
this.collection.refresh();
$('#learner-app-focusable').focus();
} }
}); });
...@@ -263,9 +326,10 @@ define([ ...@@ -263,9 +326,10 @@ define([
}, },
regions: { regions: {
search: '.learners-search', search: '.learners-search-container',
table: '.learners-table', table: '.learners-table',
paginator: '.learners-paging-footer' paginator: '.learners-paging-footer',
cohortFilter: '.learners-cohort-filter-container'
}, },
initialize: function (options) { initialize: function (options) {
...@@ -325,6 +389,11 @@ define([ ...@@ -325,6 +389,11 @@ define([
this.showChildView('paginator', new PagingFooter({ this.showChildView('paginator', new PagingFooter({
collection: this.options.collection, goBackFirstOnSort: false collection: this.options.collection, goBackFirstOnSort: false
})); }));
// Render the cohort filter
this.showChildView('cohortFilter', new CohortFiliter({
collection: this.options.collection,
courseMetadata: this.options.courseMetadata
}));
// Accessibility hacks // Accessibility hacks
this.$('table').prepend('<caption class="sr-only">' + gettext('Learner Roster') + '</caption>'); this.$('table').prepend('<caption class="sr-only">' + gettext('Learner Roster') + '</caption>');
} }
......
<% if (!_.isEmpty(cohorts)) { %>
<hr>
<label for="cohort-filter">
<%- selectDisplayName %>
</label>
<select id="cohort-filter" class="form-control">
<option selected value=""><%- allCohortsSelectedMessage %></option>
<% _.each(cohorts, function (cohortData, cohortName) { %>
<option value="<%- cohortName %>"><%- cohortData.displayName %></option>
<% }); %>
</select>
<% } %>
...@@ -3,7 +3,10 @@ ...@@ -3,7 +3,10 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-4 col-md-push-8"> <div class="col-md-4 col-md-push-8">
<div class="learners-search-container row"></div> <div class="learners-table-controls">
<div class="learners-search-container row"></div>
<div class="learners-cohort-filter-container row"></div>
</div>
</div> </div>
<div class="col-md-8 col-md-pull-4"> <div class="col-md-8 col-md-pull-4">
<div class="learners-table table-responsive row"></div> <div class="learners-table table-responsive row"></div>
......
...@@ -6,13 +6,13 @@ ...@@ -6,13 +6,13 @@
type="search" type="search"
<% if (placeholder) { %> placeholder="<%- placeholder %>" <% } %> name="<%- name %>" <% if (placeholder) { %> placeholder="<%- placeholder %>" <% } %> name="<%- name %>"
/> />
<a href="#" class="clear btn" data-backgrid-action="clear"> <a href="#" type="button" class="clear btn" data-backgrid-action="clear">
<i class="fa fa-times" aria-hidden="true"></i> <i class="fa fa-times" aria-hidden="true"></i>
<span class="sr-only"><%- clearSearchText %></span> <span class="sr-only"><%- clearSearchText %></span>
</a> </a>
</div> </div>
<span class="input-group-btn"> <span class="input-group-btn">
<a href="#" class="search btn btn-primary"> <a href="#" type="button" class="search btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i> <i class="fa fa-search" aria-hidden="true"></i>
<span class="sr-only"><%- executeSearchText %></span> <span class="sr-only"><%- executeSearchText %></span>
</a> </a>
......
...@@ -34,6 +34,16 @@ ...@@ -34,6 +34,16 @@
}; };
} }
if (!window.ngettext) {
window.ngettext = function(singularString, pluralString, count) {
if (count === 1) {
return singularString;
} else {
return pluralString;
}
};
}
// you can automatically get the test files using karma's configs // you can automatically get the test files using karma's configs
for (var file in window.__karma__.files) { for (var file in window.__karma__.files) {
if (/spec\.js$/.test(file)) { if (/spec\.js$/.test(file)) {
......
...@@ -777,31 +777,29 @@ table.dataTable thead th.sorting_desc:after { ...@@ -777,31 +777,29 @@ table.dataTable thead th.sorting_desc:after {
text-align: right; text-align: right;
} }
.learners-search { .learners-table-controls {
padding-left: 20px; padding-left: 20px;
.wrapper-search-input { .learners-search {
position: relative; .wrapper-search-input {
position: relative;
.clear { .clear {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: 0px; top: 0px;
z-index: 99; z-index: 99;
color: $edx-gray; color: $edx-gray;
&:hover { &:hover {
color: $edx-gray-d2; color: $edx-gray-d2;
}
} }
} }
}
.search { .search {
margin-left: 5px; margin-left: 5px;
}
} }
} }
.learners-cohort-filter {
padding-left: 20px;
}
} }
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