Commit d99c7d8a by Dennis Jen Committed by GitHub

Merge pull request #601 from edx/thallada/course-list-metadata

Update courses index to show course list and metadata.
parents 4a200de5 1b6de931
......@@ -8,7 +8,7 @@ services:
env:
# Make sure to update this string on every Insights or Data API release
DATA_API_VERSION: "0.17.0-rc.1"
DATA_API_VERSION: "0.20.1-rc.3"
DOCKER_COMPOSE_VERSION: "1.9.0"
before_install:
......@@ -32,6 +32,10 @@ after_success:
- docker exec insights_testing /edx/app/insights/edx_analytics_dashboard/.travis/run_coverage.sh
- codecov
after_failure:
# Print the list of running containers to rule out a killed container as a cause of failure
- docker ps
deploy:
- provider: s3
access_key_id: $S3_ACCESS_KEY_ID
......
......@@ -3,7 +3,7 @@ version: "2.1"
services:
es:
image: elasticsearch:1.5.2
analytics_api:
analyticsapi:
image: edxops/analytics_api:${DATA_API_VERSION:-latest}
container_name: analytics_api
environment:
......@@ -23,7 +23,7 @@ services:
TRAVIS_PULL_REQUEST:
DATADOG_API_KEY:
# Rest of the environment variables for testing.
API_SERVER_URL: http://analytics_api/api/v0
API_SERVER_URL: http://analyticsapi/api/v0
API_AUTH_TOKEN: edx
LMS_HOSTNAME: lms
LMS_PASSWORD: pass
......@@ -34,4 +34,4 @@ services:
DISPLAY_LEARNER_ANALYTICS: "True"
depends_on:
- "es"
- "analytics_api"
- "analyticsapi"
......@@ -76,7 +76,6 @@ validate: validate_python validate_js
demo:
python manage.py waffle_switch show_engagement_forum_activity off --create
python manage.py waffle_switch enable_course_api off --create
python manage.py waffle_switch display_names_for_course_index off --create
python manage.py waffle_switch display_course_name_in_nav off --create
# compiles djangojs and django .po and .mo files
......
......@@ -59,7 +59,6 @@ The following switches are available:
| enable_ccx_courses | Display CCX Courses in the course listing page. |
| enable_engagement_videos_pages | Enable engagement video pages. |
| enable_video_preview | Enable video preview. |
| display_names_for_course_index | Display course names on course index page. |
| display_course_name_in_nav | Display course name in navigation bar. |
| enable_performance_learning_outcome | Enable performance section with learning outcome breakdown (functionality based on tagging questions in Studio) |
| enable_learner_download | Display Download CSV button on Learner List page. |
......
......@@ -86,7 +86,20 @@ class AssertMixin(object):
element = self.page.q(css=selector)
self.assertEqual(element.text[0], DASHBOARD_FEEDBACK_EMAIL)
def assertTable(self, table_selector, columns, download_selector):
def fulfill_loading_promise(self, css_selector):
"""
Ensure the info contained by `css_selector` is loaded via AJAX.
Arguments
css_selector (string) -- CSS selector of the parent element that will contain the loading message.
"""
EmptyPromise(
lambda: 'Loading...' not in self.page.q(css=css_selector + ' .loading-container').text,
"Loading finished."
).fulfill()
def assertTable(self, table_selector, columns, download_selector=None):
# Ensure the table is loaded via AJAX
self.fulfill_loading_promise(table_selector)
......@@ -105,7 +118,8 @@ class AssertMixin(object):
rows = self.page.browser.find_elements_by_css_selector('{} tbody tr'.format(table_selector))
self.assertGreater(len(rows), 0)
self.assertValidHref(download_selector)
if download_selector is not None:
self.assertValidHref(download_selector)
def assertRowTextEquals(self, cols, expected_texts):
"""
......@@ -164,6 +178,9 @@ class FooterFeedbackMixin(FooterMixin):
class PrimaryNavMixin(CourseApiMixin):
# set to True if the URL fragement should be checked when testing the skip link
test_skip_link_url = True
def _test_user_menu(self):
"""
Verify the user menu functions properly.
......@@ -191,7 +208,7 @@ class PrimaryNavMixin(CourseApiMixin):
course_name = self.get_course_name_or_id(course_id)
self.assertEqual(element.text[0], course_name)
def _test_skip_link(self):
def _test_skip_link(self, test_url):
active_element = self.driver.switch_to.active_element
skip_link = self.page.q(css='.skip-link').results[0]
skip_link_ref = '#' + skip_link.get_attribute('href').split('#')[-1]
......@@ -202,11 +219,12 @@ class PrimaryNavMixin(CourseApiMixin):
active_element = self.driver.switch_to.active_element
active_element.send_keys(Keys.ENTER)
url_hash = self.driver.execute_script('return window.location.hash;')
self.assertEqual(url_hash, skip_link_ref)
if test_url:
url_hash = self.driver.execute_script('return window.location.hash;')
self.assertEqual(url_hash, skip_link_ref)
def test_page(self):
self._test_skip_link()
self._test_skip_link(self.test_skip_link_url)
self._test_user_menu()
self._test_active_course()
......@@ -331,19 +349,6 @@ class CoursePageTestsMixin(AnalyticsApiClientMixin, FooterLegalMixin, FooterFeed
def format_last_updated_date_and_time(self, d):
return {'update_date': d.strftime(self.DASHBOARD_DATE_FORMAT), 'update_time': self._format_last_updated_time(d)}
def fulfill_loading_promise(self, css_selector):
"""
Ensure the info contained by `css_selector` is loaded via AJAX.
Arguments
css_selector (string) -- CSS selector of the parent element that will contain the loading message.
"""
EmptyPromise(
lambda: 'Loading...' not in self.page.q(css=css_selector + ' .loading-container').text,
"Loading finished."
).fulfill()
def build_display_percentage(self, count, total, zero_percent_default='0.0%'):
if total and count:
percent = count / float(total) * 100.0
......
......@@ -9,6 +9,8 @@ _multiprocess_can_split_ = True
class CourseIndexTests(AnalyticsDashboardWebAppTestMixin, WebAppTest):
test_skip_link_url = False
def setUp(self):
super(CourseIndexTests, self).setUp()
self.page = CourseIndexPage(self.browser)
......@@ -21,18 +23,27 @@ class CourseIndexTests(AnalyticsDashboardWebAppTestMixin, WebAppTest):
"""
Course list should contain a link to the test course.
"""
course_id = TEST_COURSE_ID
course_name = self.get_course_name_or_id(course_id)
# Validate that we have a list of course names
course_names = self.page.q(css='.course-list .course a .course-name')
self.assertTrue(course_names.present)
# The element should list the test course name.
self.assertIn(course_name, course_names.text)
# Validate the course link
index = course_names.text.index(course_name)
course_links = self.page.q(css='.course-list .course a')
href = course_links.attrs('href')[index]
self.assertTrue(href.endswith(u'/courses/{}/'.format(course_id)))
# text after the new line is only visible to screen readers
columns = [
'Course Name \nclick to sort',
'Start Date \nclick to sort',
'End Date \nclick to sort',
'Total Enrollment \nclick to sort',
'Current Enrollment \nsort descending',
'Change Last Week \nclick to sort',
'Verified Enrollment \nclick to sort'
]
self.assertTable('.course-list-table', columns)
# Validate that we have a list of courses
course_ids = self.page.q(css='.course-list .course-id')
self.assertTrue(course_ids.present)
# The element should list the test course id.
self.assertIn(TEST_COURSE_ID, course_ids.text)
# Validate the course links
course_links = self.page.q(css='.course-list .course-name-cell a').attrs('href')
for link, course_id in zip(course_links, course_ids):
self.assertTrue(link.endswith(u'/courses/{}'.format(course_id.text)))
......@@ -9,6 +9,7 @@ from acceptance_tests.pages import CourseLearnersPage
@skipUnless(DISPLAY_LEARNER_ANALYTICS, 'Learner Analytics must be enabled to run CourseLearnersTests')
class CourseLearnersTests(CoursePageTestsMixin, WebAppTest):
test_skip_link_url = False
help_path = 'learners/Learner_Activity.html'
def setUp(self):
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-12-27 17:00-0500\n"
"POT-Creation-Date: 2017-01-06 14:22-0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -831,7 +831,7 @@ msgstr ""
msgid "External Tools"
msgstr ""
#: courses/templates/courses/index.html
#: courses/templates/courses/index.html courses/views/course_summaries.py
msgid "Courses"
msgstr ""
......@@ -846,18 +846,6 @@ msgid ""
"Here are the courses you currently have access to in %(application_name)s:"
msgstr ""
#: courses/templates/courses/index.html
#, python-format
msgid "New to %(application_name)s?"
msgstr ""
#: courses/templates/courses/index.html
#, python-format
msgid ""
"Click Help in the upper-right corner to get more information about "
"%(application_name)s. Send us feedback at %(email_link)s."
msgstr ""
#: courses/templates/courses/performance_answer_distribution.html
#: courses/templates/courses/performance_learning_outcomes_answer_distribution.html
#: courses/templates/courses/performance_ungraded_answer_distribution.html
......@@ -1133,6 +1121,13 @@ msgstr ""
msgid "Courseware"
msgstr ""
#. Translators: Do not translate UTC.
#: courses/views/course_summaries.py
#, python-format
msgid ""
"Course summary data was last updated %(update_date)s at %(update_time)s UTC."
msgstr ""
#: courses/views/engagement.py
msgid "Engagement Content"
msgstr ""
......
......@@ -16,17 +16,11 @@ logger = logging.getLogger(__name__)
class BasePresenter(object):
"""
This is the base class for the pages and sets up the analytics client
for the presenters to use to access the data API.
"""
def __init__(self, course_id, timeout=settings.ANALYTICS_API_DEFAULT_TIMEOUT):
def __init__(self, timeout=settings.ANALYTICS_API_DEFAULT_TIMEOUT):
self.client = Client(base_url=settings.DATA_API_URL,
auth_token=settings.DATA_API_AUTH_TOKEN,
timeout=timeout)
self.course_id = course_id
self.course = self.client.courses(self.course_id)
def get_current_date(self):
return datetime.datetime.utcnow().strftime(Client.DATE_FORMAT)
......@@ -50,6 +44,17 @@ class BasePresenter(object):
return sum(datum['count'] for datum in data)
class CoursePresenter(BasePresenter):
"""
This is the base class for the course pages and sets up the analytics client
for the presenters to use to access the data API.
"""
def __init__(self, course_id, timeout=settings.ANALYTICS_API_DEFAULT_TIMEOUT):
super(CoursePresenter, self).__init__(timeout)
self.course_id = course_id
self.course = self.client.courses(self.course_id)
class CourseAPIPresenterMixin(object):
"""
This mixin provides access to the course structure API and processes the hierarchy
......
from django.core.cache import cache
from courses.presenters import BasePresenter
class CourseSummariesPresenter(BasePresenter):
""" Presenter for the course enrollment data. """
CACHE_KEY = 'summaries'
NON_NULL_STRING_FIELDS = ['course_id', 'catalog_course', 'catalog_course_title',
'start_date', 'end_date', 'pacing_type', 'availability']
@staticmethod
def filter_summaries(all_summaries, course_ids=None):
"""Filter results to just the course IDs specified."""
if course_ids is None:
return all_summaries
else:
return [summary for summary in all_summaries if summary['course_id'] in course_ids]
def _get_all_summaries(self):
"""
Returns all course summaries. If not cached, summaries will be fetched
from the analytics data API.
"""
all_summaries = cache.get(self.CACHE_KEY)
if all_summaries is None:
all_summaries = self.client.course_summaries().course_summaries()
all_summaries = [
{field: ('' if val is None and field in self.NON_NULL_STRING_FIELDS else val)
for field, val in summary.items()} for summary in all_summaries]
cache.set(self.CACHE_KEY, all_summaries)
return all_summaries
def _get_last_updated(self, summaries):
# all the create times should be the same, so just use the first one
if summaries:
summary = summaries[0]
return self.parse_api_datetime(summary['created'])
else:
return None
def get_course_summaries(self, course_ids=None):
"""
Returns course summaries that match those listed in course_ids. If
no course IDs provided, all data will be returned.
"""
all_summaries = self._get_all_summaries()
filtered_summaries = self.filter_summaries(all_summaries, course_ids)
# sort by count by default
filtered_summaries = sorted(filtered_summaries, key=lambda summary: summary['count'], reverse=True)
return filtered_summaries, self._get_last_updated(filtered_summaries)
......@@ -13,13 +13,13 @@ from analyticsclient.exceptions import NotFoundError
from core.templatetags.dashboard_extras import metric_percentage
from courses import utils
from courses.exceptions import NoVideosError
from courses.presenters import (BasePresenter, CourseAPIPresenterMixin)
from courses.presenters import (CoursePresenter, CourseAPIPresenterMixin)
logger = logging.getLogger(__name__)
class CourseEngagementActivityPresenter(BasePresenter):
class CourseEngagementActivityPresenter(CoursePresenter):
"""
Presenter for the engagement activity page.
"""
......@@ -144,7 +144,7 @@ class CourseEngagementActivityPresenter(BasePresenter):
return summary, trends
class CourseEngagementVideoPresenter(CourseAPIPresenterMixin, BasePresenter):
class CourseEngagementVideoPresenter(CourseAPIPresenterMixin, CoursePresenter):
def blocks_have_data(self, videos):
if videos:
......
......@@ -9,7 +9,7 @@ import analyticsclient.constants.education_level as EDUCATION_LEVEL
import analyticsclient.constants.gender as GENDER
import courses.utils as utils
from courses.presenters import BasePresenter
from courses.presenters import CoursePresenter
logger = logging.getLogger(__name__)
......@@ -76,7 +76,7 @@ EDUCATION_ORDER = {
}
class CourseEnrollmentPresenter(BasePresenter):
class CourseEnrollmentPresenter(CoursePresenter):
""" Presenter for the course enrollment data. """
NUMBER_TOP_COUNTRIES = 3
......@@ -276,7 +276,7 @@ class CourseEnrollmentPresenter(BasePresenter):
return data
class CourseEnrollmentDemographicsPresenter(BasePresenter):
class CourseEnrollmentDemographicsPresenter(CoursePresenter):
""" Presenter for course enrollment demographic data. """
# ages at this and above will be binned
......
......@@ -13,7 +13,7 @@ from core.utils import (CourseStructureApiClient, sanitize_cache_key)
from common.course_structure import CourseStructure
from courses import utils
from courses.exceptions import (BaseCourseError, NoAnswerSubmissionsError)
from courses.presenters import (BasePresenter, CourseAPIPresenterMixin)
from courses.presenters import (CoursePresenter, CourseAPIPresenterMixin)
logger = logging.getLogger(__name__)
......@@ -31,7 +31,7 @@ AnswerDistributionEntry = namedtuple('AnswerDistributionEntry', [
])
class CoursePerformancePresenter(CourseAPIPresenterMixin, BasePresenter):
class CoursePerformancePresenter(CourseAPIPresenterMixin, CoursePresenter):
"""
Presenter for the performance page.
"""
......@@ -395,7 +395,7 @@ class CoursePerformancePresenter(CourseAPIPresenterMixin, BasePresenter):
return False
class TagsDistributionPresenter(CourseAPIPresenterMixin, BasePresenter):
class TagsDistributionPresenter(CourseAPIPresenterMixin, CoursePresenter):
"""
Presenter for the tags distribution page.
"""
......@@ -606,7 +606,7 @@ class TagsDistributionPresenter(CourseAPIPresenterMixin, BasePresenter):
return result
class CourseReportDownloadPresenter(BasePresenter):
class CourseReportDownloadPresenter(CoursePresenter):
"""
Presenter that can fetch temporary CSV download URLs from the data API
"""
......
{% if update_message %}
<div class="data-update-message">{{ update_message }}</div>
{% endif %}
{% if data_information_message %}
<div class="data-information-message">{{ data_information_message }}</div>
{% endif %}
......@@ -10,11 +10,6 @@
{% block child_content %}
{% endblock %}
{% block data_messaging %}
{% if update_message %}
<div class="data-update-message">{{ update_message }}</div>
{% endif %}
{% if data_information_message %}
<div class="data-information-message">{{ data_information_message }}</div>
{% endif %}
{% include "courses/_data_last_updated.html" with update_message=update_message data_information_message=data_information_message %}
{% endblock %}
{% endblock %}
......@@ -2,14 +2,15 @@
{% load i18n %}
{% load staticfiles %}
{% load dashboard_extras %}
{% load firstof from future %}
{% load rjs %}
{% block view-name %}view-course-list{% endblock view-name %}
{% block title %}{% trans "Courses" %} {{ block.super }}{% endblock title %}
{% block header-text %}
<h3>
<h3 class="row">
{% blocktrans with username=request.user.username %}Welcome, {{ username }}!{% endblocktrans %}
</h3>
{% endblock %}
......@@ -18,35 +19,25 @@
{% blocktrans %}Here are the courses you currently have access to in {{ application_name }}:{% endblocktrans %}
{% endblock intro-text %}
{% block content %}
<div class="row">
<div class="col col-12 sm-col-12 md-col-8">
<div class="course-list">
{% for course in courses %}
<div class="course">
<a href="{% url 'courses:home' course_id=course.key %}">
<span class="course-name">{% firstof course.name course.key|format_course_key %}</span>
</a>
{% if course.name %}
<div class="course-key">{{ course.key|format_course_key:" / " }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% block stylesheets %}
{{ block.super }}
<link rel="stylesheet" href="/static/bower_components/backgrid-paginator/backgrid-paginator.min.css" type="text/css">
{% endblock stylesheets %}
<div class="col col-12 sm-col-12 md-col-4">
<div class="help-msg">
<h4>{% blocktrans %}New to {{ application_name }}?{% endblocktrans %}</h4>
{% block javascript %}
{{ block.super }}
<script src="{% static_rjs 'apps/course-list/app/course-list-main.js' %}"></script>
{% endblock javascript %}
{% captureas email_link %}
<a class="feedback-email" href="mailto:{{ feedback_email }}?Subject=Feedback">
{{ feedback_email }}</a>{% endcaptureas %}
<p class="info-text">{% blocktrans trimmed %} Click Help in the upper-right corner to get more information
about {{ application_name }}. Send us feedback at {{ email_link }}.{% endblocktrans %}</p>
{% block content %}
{% block child_content %}
<section class="view-section">
<div class="course-list-app-container grid-container">
{% include "loading.html" %}
</div>
</div>
</div>
</section>
{% endblock %}
{% block data_messaging %}
{% include "courses/_data_last_updated.html" with update_message=update_message data_information_message=data_information_message %}
{% endblock %}
{% endblock %}
from ddt import (
data,
ddt
)
import mock
from django.test import (
override_settings,
TestCase
)
from courses.presenters.course_summaries import CourseSummariesPresenter
from courses.tests import utils
from courses.tests.utils import CourseSamples
@ddt
class CourseSummariesPresenterTests(TestCase):
@property
def mock_api_response(self):
'''
Returns a mocked API response for two courses including some null fields.
'''
return [{
'course_id': CourseSamples.DEPRECATED_DEMO_COURSE_ID,
'catalog_course_title': 'Deprecated demo course',
'catalog_course': 'edX+demo.1x',
'start_date': '2016-03-07T050000',
'end_date': '2016-04-18T080000',
'pacing_type': 'instructor_paced',
'availability': 'Archived',
'count': 4,
'cumulative_count': 4,
'count_change_7_days': 4,
'enrollment_modes': {
'audit': {
'count': 4,
'cumulative_count': 4,
'count_change_7_days': 4
},
'credit': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
},
'verified': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
},
'professional': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
},
'honor': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
}
},
'created': utils.CREATED_DATETIME_STRING,
}, {
'course_id': CourseSamples.DEMO_COURSE_ID,
'catalog_course_title': 'Demo Course',
'catalog_course': None,
'start_date': None,
'end_date': None,
'pacing_type': None,
'availability': None,
'count': 3884,
'cumulative_count': 5106,
'count_change_7_days': 0,
'enrollment_modes': {
'audit': {
'count': 832,
'cumulative_count': 1007,
'count_change_7_days': 0
},
'credit': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
},
'verified': {
'count': 12,
'cumulative_count': 12,
'count_change_7_days': 0
},
'professional': {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0
},
'honor': {
'count': 3040,
'cumulative_count': 4087,
'count_change_7_days': 0
}
},
'created': utils.CREATED_DATETIME_STRING,
}]
def get_expected_summaries(self, course_ids=None):
''''Expected results with default values, sorted, and filtered to course_ids.'''
if course_ids is None:
course_ids = [CourseSamples.DEMO_COURSE_ID,
CourseSamples.DEPRECATED_DEMO_COURSE_ID]
summaries = [summary for summary in self.mock_api_response if summary['course_id'] in course_ids]
# fill in with defaults
for summary in summaries:
for field in CourseSummariesPresenter.NON_NULL_STRING_FIELDS:
if summary[field] is None:
summary[field] = ''
# sort by count
return sorted(summaries, key=lambda summary: summary['count'], reverse=True)
@override_settings(CACHES={
'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
}
})
@data(
None,
[CourseSamples.DEMO_COURSE_ID],
[CourseSamples.DEPRECATED_DEMO_COURSE_ID],
[CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID],
)
def test_get_summaries(self, course_ids):
''''Test courses filtered from API response.'''
presenter = CourseSummariesPresenter()
with mock.patch('analyticsclient.course_summaries.CourseSummaries.course_summaries',
mock.Mock(return_value=self.mock_api_response)):
actual_summaries, last_updated = presenter.get_course_summaries(course_ids=course_ids)
self.assertListEqual(actual_summaries, self.get_expected_summaries(course_ids))
self.assertEqual(last_updated, utils.CREATED_DATETIME)
......@@ -24,7 +24,7 @@ from common.tests.course_fixtures import (
)
from courses.exceptions import NoVideosError
from courses.presenters import BasePresenter
from courses.presenters import CoursePresenter
from courses.presenters.engagement import (CourseEngagementActivityPresenter, CourseEngagementVideoPresenter)
from courses.presenters.enrollment import (CourseEnrollmentPresenter, CourseEnrollmentDemographicsPresenter)
from courses.presenters.performance import (
......@@ -39,13 +39,13 @@ from courses.tests.factories import (CourseEngagementDataFactory, CoursePerforma
class BasePresenterTests(TestCase):
def setUp(self):
self.presenter = BasePresenter('edX/DemoX/Demo_Course')
self.presenter = CoursePresenter('edX/DemoX/Demo_Course')
def test_init(self):
presenter = BasePresenter('edX/DemoX/Demo_Course')
presenter = CoursePresenter('edX/DemoX/Demo_Course')
self.assertEqual(presenter.client.timeout, settings.ANALYTICS_API_DEFAULT_TIMEOUT)
presenter = BasePresenter('edX/DemoX/Demo_Course', timeout=15)
presenter = CoursePresenter('edX/DemoX/Demo_Course', timeout=15)
self.assertEqual(presenter.client.timeout, 15)
def test_parse_api_date(self):
......
......@@ -15,12 +15,14 @@ from waffle.testutils import override_switch
from core.tests.test_views import RedirectTestCaseMixin, UserTestCaseMixin
from courses.permissions import set_user_course_permissions, revoke_user_course_permissions
from courses.tests.utils import set_empty_permissions, get_mock_api_enrollment_data, mock_course_name
from courses.tests.utils import (
CourseSamples,
get_mock_api_enrollment_data,
mock_course_name,
set_empty_permissions,
)
DEMO_COURSE_ID = 'course-v1:edX+DemoX+Demo_2014'
DEPRECATED_DEMO_COURSE_ID = 'edX/DemoX/Demo_Course'
logger = logging.getLogger(__name__)
......@@ -67,20 +69,6 @@ class CourseAPIMixin(object):
body.update(extra)
self.mock_course_api(path, body)
@property
def course_api_course_list(self):
return {
'pagination': {
'next': None,
},
'results': [{'id': course_key, 'name': 'Test ' + course_key}
for course_key in [DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID]],
}
def mock_course_list(self):
path = '{api}/courses/'.format(api=settings.COURSE_API_URL)
self.mock_course_api(path, self.course_api_course_list)
class PermissionsTestMixin(object):
def tearDown(self):
......@@ -109,10 +97,10 @@ class AuthTestMixin(MockApiTestMixin, PermissionsTestMixin, RedirectTestCaseMixi
def setUp(self):
super(AuthTestMixin, self).setUp()
self.grant_permission(self.user, DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
self.grant_permission(self.user, CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
self.login()
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def test_authentication(self, course_id):
"""
Users must be logged in to view the page.
......@@ -130,7 +118,7 @@ class AuthTestMixin(MockApiTestMixin, PermissionsTestMixin, RedirectTestCaseMixi
response = self.client.get(self.path(course_id=course_id))
self.assertRedirectsNoFollow(response, settings.LOGIN_URL, next=self.path(course_id=course_id))
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
@mock.patch('courses.permissions.refresh_user_course_permissions', mock.Mock(side_effect=set_empty_permissions))
def test_authorization(self, course_id):
"""
......@@ -195,7 +183,7 @@ class CourseViewTestMixin(CourseAPIMixin, NavAssertMixin, ViewTestMixin):
raise NotImplementedError
@httpretty.activate
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
@override_switch('enable_course_api', active=True)
@override_switch('display_course_name_in_nav', active=True)
def test_valid_course(self, course_id):
......@@ -205,7 +193,7 @@ class CourseViewTestMixin(CourseAPIMixin, NavAssertMixin, ViewTestMixin):
def assertValidMissingDataContext(self, context):
raise NotImplementedError
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def test_missing_data(self, course_id):
with mock.patch(self.presenter_method, mock.Mock(side_effect=NotFoundError)):
response = self.client.get(self.path(course_id=course_id))
......@@ -375,7 +363,7 @@ class CourseStructureViewMixin(NavAssertMixin, ViewTestMixin):
Additional assertions should be added to validate page content.
"""
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......@@ -414,7 +402,7 @@ class CourseStructureViewMixin(NavAssertMixin, ViewTestMixin):
# We need to break the methods that we normally patch.
self.stop_patching()
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
self.mock_course_detail(course_id)
path = self.path(course_id=course_id)
......
import json
from ddt import data, ddt
import mock
from django.test import TestCase
from courses.tests.test_views import ViewTestMixin
from courses.exceptions import PermissionsRetrievalFailedError
from courses.tests.test_middleware import CoursePermissionsExceptionMixin
import courses.tests.utils as utils
from courses.tests.utils import CourseSamples
@ddt
class CourseSummariesViewTests(ViewTestMixin, CoursePermissionsExceptionMixin, TestCase):
viewname = 'courses:index'
def setUp(self):
super(CourseSummariesViewTests, self).setUp()
self.grant_permission(self.user, CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def get_mock_data(self, course_ids):
return [{'course_id': course_id} for course_id in course_ids], utils.CREATED_DATETIME
def assertCourseListEquals(self, courses):
response = self.client.get(self.path())
self.assertEqual(response.status_code, 200)
self.assertListEqual(response.context['courses'], courses)
def expected_summaries(self, course_ids):
return self.get_mock_data(course_ids)[0]
@data(
[CourseSamples.DEMO_COURSE_ID],
[CourseSamples.DEPRECATED_DEMO_COURSE_ID],
[CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID]
)
def test_get(self, course_ids):
"""
Test data is returned in the correct hierarchy.
"""
presenter_method = 'courses.presenters.course_summaries.CourseSummariesPresenter.get_course_summaries'
mock_data = self.get_mock_data(course_ids)
with mock.patch(presenter_method, return_value=mock_data):
response = self.client.get(self.path())
self.assertEqual(response.status_code, 200)
context = response.context
page_data = json.loads(context['page_data'])
self.assertListEqual(page_data['course']['course_list_json'], self.expected_summaries(course_ids))
def test_get_unauthorized(self):
""" The view should raise an error if the user has no course permissions. """
self.grant_permission(self.user)
response = self.client.get(self.path())
self.assertEqual(response.status_code, 403)
@mock.patch('courses.permissions.get_user_course_permissions',
mock.Mock(side_effect=PermissionsRetrievalFailedError))
def test_get_with_permissions_error(self):
response = self.client.get(self.path())
self.assertIsPermissionsRetrievalFailedResponse(response)
......@@ -4,10 +4,17 @@ from django.test import TestCase
import mock
from analyticsclient.exceptions import NotFoundError
from courses.tests.test_views import ViewTestMixin, DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID
from courses.tests.utils import convert_list_of_dicts_to_csv, get_mock_api_enrollment_geography_data, \
get_mock_api_enrollment_data, get_mock_api_course_activity, get_mock_api_enrollment_age_data, \
get_mock_api_enrollment_education_data, get_mock_api_enrollment_gender_data
from courses.tests.test_views import ViewTestMixin
from courses.tests.utils import (
convert_list_of_dicts_to_csv,
CourseSamples,
get_mock_api_course_activity,
get_mock_api_enrollment_age_data,
get_mock_api_enrollment_data,
get_mock_api_enrollment_education_data,
get_mock_api_enrollment_gender_data,
get_mock_api_enrollment_geography_data,
)
@ddt
......@@ -24,7 +31,7 @@ class CourseCSVTestMixin(ViewTestMixin):
self.assertResponseContentType(response, 'text/csv')
# Check filename
csv_prefix = u'edX-DemoX-Demo_2014' if course_id == DEMO_COURSE_ID else u'edX-DemoX-Demo_Course'
csv_prefix = u'edX-DemoX-Demo_2014' if course_id == CourseSamples.DEMO_COURSE_ID else u'edX-DemoX-Demo_Course'
filename = '{0}--{1}.csv'.format(csv_prefix, self.base_file_name)
self.assertResponseFilename(response, filename)
......@@ -41,13 +48,13 @@ class CourseCSVTestMixin(ViewTestMixin):
with mock.patch(self.api_method, return_value=csv_data):
self.assertIsValidCSV(course_id, csv_data)
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def test_response_no_data(self, course_id):
# Create an "empty" CSV that only has headers
csv_data = convert_list_of_dicts_to_csv([], self.column_headings)
self._test_csv(course_id, csv_data)
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def test_response(self, course_id):
csv_data = self.get_mock_data(course_id)
csv_data = convert_list_of_dicts_to_csv(csv_data)
......
......@@ -13,12 +13,12 @@ import analyticsclient.constants.activity_type as AT
from courses.tests.factories import CourseEngagementDataFactory
from courses.tests.test_views import (
DEMO_COURSE_ID,
CourseViewTestMixin,
PatchMixin,
CourseStructureViewMixin,
CourseAPIMixin)
from courses.tests import utils
from courses.tests.utils import CourseSamples
@override_switch('enable_engagement_videos_pages', active=True)
......@@ -198,8 +198,8 @@ class CourseEngagementVideoMixin(CourseEngagementViewTestMixin, CourseStructureV
@patch('courses.presenters.engagement.CourseEngagementVideoPresenter.sections', Mock(return_value=dict()))
def test_missing_sections(self):
""" Every video page will use sections and will return 200 if sections aren't available. """
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
# base page will should return a 200 even if no sections found
self.assertEqual(response.status_code, 200)
......@@ -229,8 +229,8 @@ class EngagementVideoCourseSectionTest(CourseEngagementVideoMixin, TestCase):
@httpretty.activate
@patch('courses.presenters.engagement.CourseEngagementVideoPresenter.section', Mock(return_value=None))
def test_missing_section(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID, section_id='Invalid'))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID, section_id='Invalid'))
self.assertEqual(response.status_code, 404)
......@@ -258,8 +258,9 @@ class EngagementVideoCourseSubsectionTest(CourseEngagementVideoMixin, TestCase):
@httpretty.activate
@patch('courses.presenters.engagement.CourseEngagementVideoPresenter.subsection', Mock(return_value=None))
def test_missing_subsection(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID, section_id='Invalid', subsection_id='Nope'))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(
course_id=CourseSamples.DEMO_COURSE_ID, section_id='Invalid', subsection_id='Nope'))
self.assertEqual(response.status_code, 404)
......@@ -292,15 +293,15 @@ class EngagementVideoCourseTimelineTest(CourseEngagementVideoMixin, TestCase):
@patch('courses.presenters.engagement.CourseEngagementVideoPresenter.subsection_child', Mock(return_value=None))
def test_missing_video_module(self):
""" Every video page will use sections and will return 200 if sections aren't available. """
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
# base page will should return a 200 even if no sections found
self.assertEqual(response.status_code, 404)
@httpretty.activate
@patch('courses.presenters.engagement.CourseEngagementVideoPresenter.get_video_timeline', Mock(return_value=None))
def test_missing_video_data(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
# page will still be displayed, but with error messages
self.assertEqual(response.status_code, 200)
......@@ -13,7 +13,8 @@ from django.test.utils import override_settings
from waffle.testutils import override_flag, override_switch
from courses.tests.test_views import DEMO_COURSE_ID, ViewTestMixin
from courses.tests.test_views import ViewTestMixin
from courses.tests.utils import CourseSamples
@httpretty.activate
......@@ -34,7 +35,7 @@ class LearnersViewTests(ViewTestMixin, TestCase):
httpretty.GET,
'{data_api_url}/course_learner_metadata/{course_id}/'.format(
data_api_url=settings.DATA_API_URL,
course_id=DEMO_COURSE_ID,
course_id=CourseSamples.DEMO_COURSE_ID,
),
body=json.dumps(course_metadata_payload),
status=course_metadata_status
......@@ -42,13 +43,13 @@ class LearnersViewTests(ViewTestMixin, TestCase):
self.addCleanup(httpretty.reset)
def _get(self):
return self.client.get(self.path(course_id=DEMO_COURSE_ID))
return self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
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=DEMO_COURSE_ID
course_id=CourseSamples.DEMO_COURSE_ID
),
}
self.assertDictContainsSubset(dict(expected_context_subset.items()), response.context)
......
......@@ -2,14 +2,14 @@ from django.test import TestCase
from django.test.utils import override_settings
import mock
from courses.tests.test_views import DEPRECATED_DEMO_COURSE_ID
from courses.tests.utils import CourseSamples
from courses.views import CourseValidMixin
class CourseValidMixinTests(TestCase):
def setUp(self):
self.mixin = CourseValidMixin()
self.mixin.course_id = DEPRECATED_DEMO_COURSE_ID
self.mixin.course_id = CourseSamples.DEPRECATED_DEMO_COURSE_ID
@override_settings(LMS_COURSE_VALIDATION_BASE_URL=None)
def test_no_validation_url(self):
......
......@@ -9,11 +9,8 @@ from django.test import TestCase
from analyticsclient.exceptions import NotFoundError
from courses.tests.test_views import ViewTestMixin, CourseViewTestMixin, DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID, \
CourseAPIMixin
from courses.exceptions import PermissionsRetrievalFailedError
from courses.tests.test_middleware import CoursePermissionsExceptionMixin
from courses.tests.test_views import CourseViewTestMixin
from courses.tests.utils import CourseSamples
@ddt
......@@ -31,8 +28,8 @@ class CourseHomeViewTests(CourseViewTestMixin, TestCase):
"""
Assert that the Problem Response Report download link is or is not present.
"""
self.mock_course_detail(DEMO_COURSE_ID, {})
path = self.path(course_id=DEMO_COURSE_ID)
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID, {})
path = self.path(course_id=CourseSamples.DEMO_COURSE_ID)
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['page_title'], 'Course Home')
......@@ -40,15 +37,15 @@ class CourseHomeViewTests(CourseViewTestMixin, TestCase):
performance_views = [item['view'] for item in performance_item['items']]
self.assertEqual('courses:csv:performance_problem_responses' in performance_views, expected)
@data(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
@data(CourseSamples.DEMO_COURSE_ID, CourseSamples.DEPRECATED_DEMO_COURSE_ID)
def test_missing_data(self, course_id):
self.skipTest('The course homepage does not check for the existence of a course.')
@httpretty.activate
@override_switch('enable_course_api', active=True)
def test_course_overview(self):
self.mock_course_detail(DEMO_COURSE_ID, {'start': '2015-01-23T00:00:00Z', 'end': None})
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID, {'start': '2015-01-23T00:00:00Z', 'end': None})
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
self.assertEqual(response.status_code, 200)
overview_data = {k: v for k, v in response.context['course_overview']}
......@@ -56,18 +53,19 @@ class CourseHomeViewTests(CourseViewTestMixin, TestCase):
self.assertEqual(overview_data.get('Status'), 'In Progress')
links = {link['title']: link['url'] for link in response.context['external_course_tools']}
self.assertEqual(len(links), 3)
self.assertEqual(links.get('Instructor Dashboard'), 'http://lms-host/{}/instructor'.format(DEMO_COURSE_ID))
self.assertEqual(links.get('Courseware'), 'http://lms-host/{}/courseware'.format(DEMO_COURSE_ID))
self.assertEqual(links.get('Studio'), 'http://cms-host/{}'.format(DEMO_COURSE_ID))
self.assertEqual(links.get('Instructor Dashboard'),
'http://lms-host/{}/instructor'.format(CourseSamples.DEMO_COURSE_ID))
self.assertEqual(links.get('Courseware'), 'http://lms-host/{}/courseware'.format(CourseSamples.DEMO_COURSE_ID))
self.assertEqual(links.get('Studio'), 'http://cms-host/{}'.format(CourseSamples.DEMO_COURSE_ID))
@httpretty.activate
@override_switch('enable_course_api', active=True)
def test_course_ended(self):
self.mock_course_detail(DEMO_COURSE_ID, {
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID, {
'start': '2015-01-01T00:00:00Z',
'end': '2015-02-15T00:00:00Z'
})
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
self.assertEqual(response.status_code, 200)
overview_data = {k: v for k, v in response.context['course_overview']}
self.assertEqual(overview_data.get('Start Date'), 'January 01, 2015')
......@@ -96,61 +94,3 @@ class CourseHomeViewTests(CourseViewTestMixin, TestCase):
else:
mock_get_report_info.side_effect = NotFoundError
self.assert_performance_report_link_present(available)
class CourseIndexViewTests(CourseAPIMixin, ViewTestMixin, CoursePermissionsExceptionMixin, TestCase):
viewname = 'courses:index'
def setUp(self):
super(CourseIndexViewTests, self).setUp()
self.grant_permission(self.user, DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID)
self.courses = self._create_course_list(DEPRECATED_DEMO_COURSE_ID, DEMO_COURSE_ID)
def assertCourseListEquals(self, courses):
response = self.client.get(self.path())
self.assertEqual(response.status_code, 200)
self.assertListEqual(response.context['courses'], courses)
def _create_course_list(self, *course_keys, **kwargs):
with_name = kwargs.get('with_name', False)
return [{'key': key, 'name': 'Test ' + key if with_name else None} for key in course_keys]
def test_get(self):
""" If the user is authorized, the view should return a list of all accessible courses. """
self.courses.sort(key=lambda course: (course['name'] or course['key'] or '').lower())
self.assertCourseListEquals(self.courses)
def test_get_with_mixed_permissions(self):
""" If user only has permission to one course, course list should only display the one course. """
self.revoke_permissions(self.user)
self.grant_permission(self.user, DEMO_COURSE_ID)
courses = self._create_course_list(DEMO_COURSE_ID)
self.assertCourseListEquals(courses)
def test_get_unauthorized(self):
""" The view should raise an error if the user has no course permissions. """
self.grant_permission(self.user)
response = self.client.get(self.path())
self.assertEqual(response.status_code, 403)
@httpretty.activate
@override_switch('enable_course_api', active=True)
@override_switch('display_names_for_course_index', active=True)
def test_get_with_course_api(self):
""" Verify that the view properly retrieves data from the course API. """
self.mock_course_list()
courses = self._create_course_list(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID, with_name=True)
self.assertIsNotNone(httpretty.last_request())
self.assertCourseListEquals(courses)
# Test with mixed permissions
self.revoke_permissions(self.user)
self.grant_permission(self.user, DEMO_COURSE_ID)
courses = self._create_course_list(DEMO_COURSE_ID, with_name=True)
self.assertCourseListEquals(courses)
@mock.patch('courses.permissions.get_user_course_permissions',
mock.Mock(side_effect=PermissionsRetrievalFailedError))
def test_get_with_permissions_error(self):
response = self.client.get(self.path())
self.assertIsPermissionsRetrievalFailedResponse(response)
......@@ -17,7 +17,8 @@ from analyticsclient.exceptions import ClientError, NotFoundError
from courses.tests import utils
from courses.tests.factories import CoursePerformanceDataFactory, TagsDistributionDataFactory
from courses.tests.test_views import (DEMO_COURSE_ID, CourseStructureViewMixin, CourseAPIMixin, PatchMixin)
from courses.tests.test_views import (CourseStructureViewMixin, CourseAPIMixin, PatchMixin)
from courses.tests.utils import CourseSamples
logger = logging.getLogger(__name__)
......@@ -30,7 +31,7 @@ class CoursePerformanceViewTestMixin(PatchMixin, CourseStructureViewMixin, Cours
def setUp(self):
super(CoursePerformanceViewTestMixin, self).setUp()
self.factory = CoursePerformanceDataFactory()
self.factory.course_id = DEMO_COURSE_ID
self.factory.course_id = CourseSamples.DEMO_COURSE_ID
def get_mock_data(self, course_id):
# The subclasses don't need this.
......@@ -104,7 +105,7 @@ class CoursePerformanceViewTestMixin(PatchMixin, CourseStructureViewMixin, Cours
"""
# Nearly all course performance pages rely on retrieving the grading policy.
# Break that endpoint to simulate an error.
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
api_path = self.GRADING_POLICY_API_TEMPLATE.format(course_id=course_id)
self.mock_course_api(api_path, status=500)
......@@ -206,7 +207,7 @@ class CoursePerformanceAnswerDistributionMixin(CoursePerformanceViewTestMixin):
@httpretty.activate
def _test_valid_course(self, rv):
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......@@ -262,7 +263,7 @@ class CoursePerformanceAnswerDistributionMixin(CoursePerformanceViewTestMixin):
"""
The view should return HTTP 404 if the answer distribution data is missing.
"""
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......@@ -363,7 +364,7 @@ class CoursePerformanceGradedContentByTypeViewTests(CoursePerformanceGradedMixin
Assignments might be missing if the assignment type is invalid or the course is incomplete.
"""
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......@@ -410,7 +411,7 @@ class CoursePerformanceAssignmentViewTests(CoursePerformanceGradedMixin, TestCas
Assignments might be missing if the assignment type is invalid or the course is incomplete.
"""
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......@@ -426,8 +427,8 @@ class CoursePerformanceUngradedContentViewTests(CoursePerformanceUngradedMixin,
@httpretty.activate
@patch('courses.presenters.performance.CoursePerformancePresenter.sections', Mock(return_value=None))
def test_missing_sections(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID))
# base page will should return a 200 even if no sections found
self.assertEqual(response.status_code, 200)
......@@ -454,8 +455,8 @@ class CoursePerformanceUngradedSectionViewTests(CoursePerformanceUngradedMixin,
@httpretty.activate
@patch('courses.presenters.performance.CoursePerformancePresenter.section', Mock(return_value=None))
def test_missing_subsections(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID, section_id='Invalid'))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID, section_id='Invalid'))
self.assertEqual(response.status_code, 404)
......@@ -484,8 +485,9 @@ class CoursePerformanceUngradedSubsectionViewTests(CoursePerformanceUngradedMixi
@httpretty.activate
@patch('courses.presenters.performance.CoursePerformancePresenter.subsection', Mock(return_value=None))
def test_missing_subsection(self):
self.mock_course_detail(DEMO_COURSE_ID)
response = self.client.get(self.path(course_id=DEMO_COURSE_ID, section_id='Invalid', subsection_id='Nope'))
self.mock_course_detail(CourseSamples.DEMO_COURSE_ID)
response = self.client.get(self.path(
course_id=CourseSamples.DEMO_COURSE_ID, section_id='Invalid', subsection_id='Nope'))
self.assertEqual(response.status_code, 404)
......@@ -498,7 +500,7 @@ class CoursePerformanceLearningOutcomesViewTestMixin(CoursePerformanceViewTestMi
def setUp(self):
super(CoursePerformanceLearningOutcomesViewTestMixin, self).setUp()
self.tags_factory = TagsDistributionDataFactory(self.tags_factory_init_data)
self.tags_factory.course_id = DEMO_COURSE_ID
self.tags_factory.course_id = CourseSamples.DEMO_COURSE_ID
def _check_invalid_course(self, expected_status_code=404):
course_id = 'fakeOrg/soFake/Fake_Course'
......@@ -655,7 +657,7 @@ class CoursePerformanceLearningOutcomesAnswersDistributionViewTests(
Mock(return_value=self.tags_factory.structure)):
with patch('analyticsclient.course.Course.problems_and_tags',
Mock(return_value=self.tags_factory.problems_and_tags)):
course_id = DEMO_COURSE_ID
course_id = CourseSamples.DEMO_COURSE_ID
# Mock the course details
self.mock_course_detail(course_id)
......
......@@ -20,6 +20,12 @@ GAP_START = 2
GAP_END = 4
class CourseSamples(object):
"""Example course IDs for testing with."""
DEMO_COURSE_ID = 'course-v1:edX+DemoX+Demo_2014'
DEPRECATED_DEMO_COURSE_ID = 'edX/DemoX/Demo_Course'
def get_mock_api_enrollment_data(course_id):
data = []
start_date = datetime.date(year=2014, month=1, day=1)
......
......@@ -3,7 +3,14 @@ from django.conf import settings
from django.conf.urls import url, include
from courses import views
from courses.views import enrollment, engagement, performance, csv, learners
from courses.views import (
course_summaries,
csv,
enrollment,
engagement,
performance,
learners,
)
CONTENT_ID_PATTERN = r'(?P<content_id>(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))'
PROBLEM_PART_ID_PATTERN = CONTENT_ID_PATTERN.replace('content_id', 'problem_part_id')
......@@ -127,6 +134,6 @@ COURSE_URLS = [
app_name = 'courses'
urlpatterns = [
url('^$', views.CourseIndex.as_view(), name='index'),
url('^$', course_summaries.CourseIndex.as_view(), name='index'),
url(r'^{}/'.format(settings.COURSE_ID_PATTERN), include(COURSE_URLS))
]
......@@ -5,7 +5,6 @@ import logging
import re
from braces.views import LoginRequiredMixin
from ccx_keys.locator import CCXLocator
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
......@@ -449,17 +448,7 @@ class CourseView(LoginRequiredMixin, CourseValidMixin, CoursePermissionMixin, Te
return context
class CourseTemplateView(ContextSensitiveHelpMixin, CourseContextMixin, CourseView):
update_message = None
@property
def help_token(self):
# Rather than duplicate the definition, simply return the page name.
page_name = get_page_name(self.page_name)
if not page_name:
page_name = 'default'
return page_name
class LastUpdatedView(object):
def get_last_updated_message(self, last_updated):
if last_updated:
return self.update_message % self.format_last_updated_date_and_time(last_updated)
......@@ -472,6 +461,18 @@ class CourseTemplateView(ContextSensitiveHelpMixin, CourseContextMixin, CourseVi
'update_time': dateformat.format(d, settings.TIME_FORMAT)}
class CourseTemplateView(LastUpdatedView, ContextSensitiveHelpMixin, CourseContextMixin, CourseView):
update_message = None
@property
def help_token(self):
# Rather than duplicate the definition, simply return the page name.
page_name = get_page_name(self.page_name)
if not page_name:
page_name = 'default'
return page_name
class CourseTemplateWithNavView(CourseNavBarMixin, CourseTemplateView):
pass
......@@ -750,57 +751,6 @@ class CourseHome(CourseTemplateWithNavView):
return context
class CourseIndex(CourseAPIMixin, LoginRequiredMixin, TrackedViewMixin, LazyEncoderMixin, TemplateView):
template_name = 'courses/index.html'
page_name = {
'scope': 'insights',
'lens': 'home',
'report': '',
'depth': ''
}
def get_context_data(self, **kwargs):
context = super(CourseIndex, self).get_context_data(**kwargs)
courses = permissions.get_user_course_permissions(self.request.user)
if not courses:
# The user is probably not a course administrator and should not be using this application.
raise PermissionDenied
courses_list = self._create_course_list(courses)
context['courses'] = courses_list
context['page_data'] = self.get_page_data(context)
return context
def _create_course_list(self, course_ids):
info = []
course_data = {}
# ccx courses are hidden on the course listing page unless enabled
if not switch_is_active('enable_ccx_courses'):
# filter ccx courses
course_ids = [course_id for course_id in course_ids
if not isinstance(CourseKey.from_string(course_id), CCXLocator)]
if self.course_api_enabled and switch_is_active('display_names_for_course_index'):
# Get data for all courses in a single API call.
_api_courses = self.get_courses()
# Create a lookup table from the data.
for course in _api_courses:
course_data[course['id']] = course['name']
for course_id in course_ids:
info.append({'key': course_id, 'name': course_data.get(course_id)})
info.sort(key=lambda course: (course.get('name', '') or course.get('key', '') or '').lower())
return info
class CourseStructureExceptionMixin(object):
"""
Catches exceptions from the course structure API. This mixin should be included before
......
import logging
from braces.views import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext_lazy as _
from courses import permissions
from courses.views import (
CourseAPIMixin,
LastUpdatedView,
LazyEncoderMixin,
TemplateView,
TrackedViewMixin,
)
from courses.presenters.course_summaries import CourseSummariesPresenter
logger = logging.getLogger(__name__)
class CourseIndex(CourseAPIMixin, LoginRequiredMixin, TrackedViewMixin, LastUpdatedView, LazyEncoderMixin,
TemplateView):
template_name = 'courses/index.html'
page_title = _('Courses')
page_name = {
'scope': 'insights',
'lens': 'home',
'report': '',
'depth': ''
}
# pylint: disable=line-too-long
# Translators: Do not translate UTC.
update_message = _('Course summary data was last updated %(update_date)s at %(update_time)s UTC.')
def get_context_data(self, **kwargs):
context = super(CourseIndex, self).get_context_data(**kwargs)
courses = permissions.get_user_course_permissions(self.request.user)
if not courses:
# The user is probably not a course administrator and should not be using this application.
raise PermissionDenied
presenter = CourseSummariesPresenter()
summaries, last_updated = presenter.get_course_summaries(courses)
context.update({
'update_message': self.get_last_updated_message(last_updated)
})
data = {
'course_list_json': summaries,
}
context['js_data']['course'] = data
context['page_data'] = self.get_page_data(context)
return context
......@@ -4,7 +4,6 @@ from analytics_dashboard.settings.base import *
from analytics_dashboard.settings.yaml_config import *
from analytics_dashboard.settings.logger import get_logger_config
if not DEBUG:
# Enable offline compression of CSS/JS
COMPRESS_ENABLED = True
......
define(function(require) {
'use strict';
var AlertView = require('learners/common/views/alert-view');
var AlertView = require('components/alert/views/alert-view');
describe('AlertView', function() {
it('throws exception for invalid alert types', function() {
......
......@@ -7,7 +7,7 @@ define(function(require) {
var _ = require('underscore'),
Marionette = require('marionette'),
alertTemplate = require('text!learners/common/templates/alert.underscore'),
alertTemplate = require('text!components/alert/templates/alert.underscore'),
AlertView;
......
......@@ -3,7 +3,7 @@ define(function(require) {
var _ = require('underscore'),
TrackingModel = require('models/tracking-model'),
DownloadDataView = require('learners/common/views/download-data'),
DownloadDataView = require('components/download/views/download-data'),
LearnerCollection = require('learners/common/collections/learners');
describe('DownloadDataView', function() {
......@@ -64,14 +64,14 @@ define(function(require) {
[this.user],
{
url: 'http://example.com',
downloadUrl: '/learners.csv'
downloadUrl: '/list.csv'
}
)
});
expect(downloadDataView.getDownloadUrl()).toBe('/learners.csv?page=1&course_id=undefined');
expect(downloadDataView.getDownloadUrl()).toBe('/list.csv?page=1&course_id=undefined');
templateVars = downloadDataView.templateHelpers();
expect(templateVars.hasDownloadData).toBe(true);
expect(templateVars.downloadUrl).toBe('/learners.csv?page=1&course_id=undefined');
expect(templateVars.downloadUrl).toBe('/list.csv?page=1&course_id=undefined');
});
it('changes the downloadUrl query string based on search filters', function() {
......@@ -79,7 +79,7 @@ define(function(require) {
var collection = new LearnerCollection([this.user],
{
url: 'http://example.com',
downloadUrl: '/learners.csv',
downloadUrl: '/list.csv',
courseId: 'course-v1:Demo:test'
}),
downloadDataView = new DownloadDataView({
......@@ -87,7 +87,7 @@ define(function(require) {
});
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?page=1' +
'&course_id=course-v1%3ADemo%3Atest'
);
......@@ -95,7 +95,7 @@ define(function(require) {
// set a filter field
collection.setFilterField('enrollment_mode', 'audit');
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?enrollment_mode=audit' +
'&page=1' +
'&course_id=course-v1%3ADemo%3Atest'
......@@ -104,7 +104,7 @@ define(function(require) {
// add another filter field (will maintain alphabetical order)
collection.setFilterField('alpha', 'beta');
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?enrollment_mode=audit' +
'&alpha=beta' +
'&page=1' +
......@@ -114,7 +114,7 @@ define(function(require) {
// unset filter field restores original URL
collection.unsetAllFilterFields();
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?page=1' +
'&course_id=course-v1%3ADemo%3Atest'
);
......@@ -124,7 +124,7 @@ define(function(require) {
var collection = new LearnerCollection([this.user],
{
url: 'http://example.com',
downloadUrl: '/learners.csv?fields=abc,def&other=ghi',
downloadUrl: '/list.csv?fields=abc,def&other=ghi',
courseId: 'course-v1:Demo:test'
}),
downloadDataView = new DownloadDataView({
......@@ -133,7 +133,7 @@ define(function(require) {
// query string parameters will be sorted alphabetically
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?page=1' +
'&course_id=course-v1%3ADemo%3Atest' +
'&fields=abc%2Cdef' +
......@@ -143,7 +143,7 @@ define(function(require) {
// set a filter field
collection.setFilterField('enrollment_mode', 'audit');
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?enrollment_mode=audit' +
'&page=1' +
'&course_id=course-v1%3ADemo%3Atest' +
......@@ -154,7 +154,7 @@ define(function(require) {
// unset filter field restores original URL
collection.unsetAllFilterFields();
expect(downloadDataView.getDownloadUrl()).toBe(
'/learners.csv' +
'/list.csv' +
'?page=1' +
'&course_id=course-v1%3ADemo%3Atest' +
'&fields=abc%2Cdef' +
......@@ -167,7 +167,7 @@ define(function(require) {
var collection = new LearnerCollection([{}],
{
url: 'http://example.com',
downloadUrl: '/learners.csv?fields=abc,def',
downloadUrl: '/list.csv?fields=abc,def',
courseId: 'course-v1:Demo:test'
}),
downloadDataView = new DownloadDataView(_.extend({
......
<% if (hasDownloadData) { %>
<div class="section-action">
<a class="btn btn-default action-download-data" role="button"
href="<%- downloadUrl %>" data-role="learner-data-csv" data-track-type="click"
href="<%- downloadUrl %>" data-role="list-data-csv" data-track-type="click"
data-track-event="edx.bi.csv.downloaded" data-track-category="<%- trackCategory %>">
<span class="ico fa fa-download" aria-hidden="true"></span> <%- downloadDataTitle %>
<span class="sr-only"><%- downloadDataMessage %></span>
......
......@@ -6,7 +6,7 @@ define(function(require) {
Marionette = require('marionette'),
Utils = require('utils/utils'),
downloadDataTemplate = require('text!learners/common/templates/download-data.underscore'),
downloadDataTemplate = require('text!components/download/templates/download-data.underscore'),
DownloadDataView;
DownloadDataView = Marionette.ItemView.extend({
......
define(function(require) {
'use strict';
var PagingCollection = require('uitk/pagination/paging-collection'),
ListUtils = require('components/utils/utils'),
Utils = require('utils/utils'),
_ = require('underscore'),
ListCollection;
ListCollection = PagingCollection.extend({
initialize: function(models, options) {
PagingCollection.prototype.initialize.call(this, options);
this.url = options.url;
this.downloadUrl = options.downloadUrl;
},
fetch: function(options) {
return PagingCollection.prototype.fetch.call(this, options)
.fail(ListUtils.handleAjaxFailure.bind(this));
},
state: {
pageSize: 25
},
// 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();
},
/**
* 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://.../#?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 = [];
// 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});
return Utils.toQueryString(orderedParams);
},
/**
* 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);
// NOTE: if in client mode, the sort function still needs to be called on the collection.
// And, if in server mode, a fetch needs to happen to retrieve the sorted results.
}
}
}
});
return ListCollection;
});
define(function(require) {
'use strict';
var URI = require('URI'),
ListCollection = require('components/generic-list/common/collections/collection');
describe('ListCollection', function() {
var list,
server,
url,
lastRequest,
getUriForLastRequest;
lastRequest = function() {
return server.requests[server.requests.length - 1];
};
getUriForLastRequest = function() {
return new URI(lastRequest().url);
};
beforeEach(function() {
server = sinon.fakeServer.create();
list = new ListCollection(null, {url: '/endpoint/'});
});
afterEach(function() {
server.restore();
});
it('passes the expected url parameter to the collection fetch', function() {
list.fetch();
url = getUriForLastRequest(server);
expect(url.path()).toEqual('/endpoint/');
});
it('passes the expected pagination querystring parameters', function() {
list.setPage(1);
url = getUriForLastRequest(server);
expect(url.path()).toEqual('/endpoint/');
expect(url.query(true)).toEqual({page: '1', page_size: '25'});
});
it('triggers an event when server gateway timeouts occur', function() {
var spy = {eventCallback: function() {}};
spyOn(spy, 'eventCallback');
list.once('serverError', spy.eventCallback);
list.fetch();
lastRequest().respond(504, {}, '');
expect(spy.eventCallback).toHaveBeenCalled();
});
describe('Backgrid Paginator shim', function() {
it('implements hasPrevious', function() {
list = new ListCollection({
num_pages: 2, count: 50, results: []
}, {state: {currentPage: 2}, url: '/endpoint/', parse: true});
expect(list.hasPreviousPage()).toBe(true);
expect(list.hasPrevious()).toBe(true);
list.state.currentPage = 1;
expect(list.hasPreviousPage()).toBe(false);
expect(list.hasPrevious()).toBe(false);
});
it('implements hasNext', function() {
list = new ListCollection({
num_pages: 2, count: 50, results: []
}, {state: {currentPage: 1}, url: '/endpoint/', parse: true});
expect(list.hasNextPage()).toBe(true);
expect(list.hasNext()).toBe(true);
list.state.currentPage = 2;
expect(list.hasNextPage()).toBe(false);
expect(list.hasNext()).toBe(false);
});
});
describe('Encoding State to a Query String', function() {
it('encodes an empty state', function() {
expect(list.getQueryString()).toBe('?page=1');
});
it('encodes the page number', function() {
list.state.currentPage = 2;
expect(list.getQueryString()).toBe('?page=2');
});
it('encodes the text search', function() {
list.setFilterField('text_search', 'foo');
expect(list.getQueryString()).toBe('?text_search=foo&page=1');
});
});
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() {
list.setStateFromQueryString('');
expect(list.state).toEqual(state);
expect(list.getActiveFilterFields()).toEqual({});
});
it('decodes the page number', function() {
state.currentPage = 2;
list.setStateFromQueryString('page=2');
expect(list.state).toEqual(state);
expect(list.getActiveFilterFields()).toEqual({});
});
it('decodes the sort', function() {
state.sortKey = 'username';
state.order = 1;
list.registerSortableField('username', 'Name (Username)');
list.setStateFromQueryString('sortKey=username&order=desc');
expect(list.state).toEqual(state);
expect(list.getActiveFilterFields()).toEqual({});
});
it('decodes the text search', function() {
list.setStateFromQueryString('text_search=foo');
expect(list.state).toEqual(state);
expect(list.getSearchString()).toEqual('foo');
});
});
});
});
define(function(require) {
'use strict';
var PageModel = require('learners/common/models/page'),
var PageModel = require('components/generic-list/common/models/page'),
$ = require('jquery');
describe('PageModel', function() {
......@@ -9,7 +9,7 @@ define(function(require) {
beforeEach(function() {
// Setup default page title
$('title').text('Learners - ' + titleConstantPart);
$('title').text('List - ' + titleConstantPart);
});
it('should have all the expected default fields', function() {
......
/**
* A type of Marionette LayoutView that contains children views.
*
* Subclass this view and set the `childViews` property of the instance during initialize. For example:
*
* this.childViews = [
* {
* region: 'results',
* class: ViewClass,
* options: {
* collection: this.options.collection,
* hasData: this.options.hasData,
* trackingModel: this.options.trackingModel
* }
* }
* ];
*
* Before the parent view is shown, each of the regions of the view will be filled with the appropriate childView.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Marionette = require('marionette'),
ParentView;
ParentView = Marionette.LayoutView.extend({
initialize: function(options) {
this.options = options || {};
},
onBeforeShow: function() {
_.each(this.childViews, _.bind(function(child) {
this.showChildView(child.region, new child.class(child.options));
}, this));
}
});
return ParentView;
});
<div class="section-data-table">
<div class="list-table table-responsive"></div>
<div class="list-paging-footer"></div>
</div>
/**
* Base class for all table header cells. Adds proper routing and icons.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Backgrid = require('backgrid'),
baseHeaderCellTemplate = require('text!components/generic-list/list/templates/base-header-cell.underscore'),
BaseHeaderCell;
BaseHeaderCell = Backgrid.HeaderCell.extend({
attributes: {
scope: 'col'
},
template: _.template(baseHeaderCellTemplate),
tooltips: {
// Inherit and fill out.
},
container: '.list-table',
initialize: function() {
Backgrid.HeaderCell.prototype.initialize.apply(this, arguments);
this.collection.on('backgrid:sort', this.onSort, this);
// Set up the tooltip
this.$el.attr('title', this.tooltips[this.column.get('name')]);
this.$el.tooltip({container: this.container});
},
render: function() {
var directionWord;
if (this.collection.state.sortKey && this.collection.state.sortKey === this.column.attributes.name) {
directionWord = this.collection.state.order === 1 ? 'descending' : 'ascending';
this.column.attributes.direction = directionWord;
}
Backgrid.HeaderCell.prototype.render.apply(this, arguments);
this.$el.html(this.template({
label: this.column.get('label')
}));
if (directionWord) { // this column is sorted
this.renderSortState(this.column, directionWord);
} else {
this.renderSortState();
}
return this;
},
onSort: function(column, direction) {
this.renderSortState(column, direction);
},
renderSortState: function(column, direction) {
var sortIcon = this.$('span.fa'),
sortDirectionMap,
directionOrNeutral;
if (column && column.cid !== this.column.cid) {
directionOrNeutral = 'neutral';
} else {
directionOrNeutral = direction || 'neutral';
}
// Maps a sort direction to its appropriate screen reader
// text and icon.
sortDirectionMap = {
// Translators: "sort ascending" describes the current sort state to the user.
ascending: {screenReaderText: gettext('sort ascending'), iconClass: 'fa fa-sort-asc'},
// Translators: "sort descending" describes the current sort state to the user.
descending: {screenReaderText: gettext('sort descending'), iconClass: 'fa fa-sort-desc'},
// eslint-disable-next-line max-len
// Translators: "click to sort" tells the user that they can click this link to sort by the current field.
neutral: {screenReaderText: gettext('click to sort'), iconClass: 'fa fa-sort'}
};
sortIcon.removeClass('fa-sort fa-sort-asc fa-sort-desc');
sortIcon.addClass(sortDirectionMap[directionOrNeutral].iconClass);
this.$('.sr-sorting-text').text(' ' + sortDirectionMap[directionOrNeutral].screenReaderText);
}
});
return BaseHeaderCell;
});
/**
* Renders a sortable, filterable, and searchable paginated table of
* learners for the Learner Analytics app.
*/
define(function(require) {
'use strict';
var ParentView = require('components/generic-list/common/views/parent-view'),
ListUtils = require('components/utils/utils'),
ListView;
// Load modules without exports
require('backgrid-filter');
require('bootstrap');
require('bootstrap_accessibility'); // adds the aria-describedby to tooltips
/**
* Wraps up the search view, table view, and pagination footer
* view.
*/
ListView = ParentView.extend({
className: 'generic-list',
initialize: function(options) {
var eventTransformers;
this.options = options || {};
eventTransformers = {
serverError: ListUtils.EventTransformers.serverErrorToAppError,
networkError: ListUtils.EventTransformers.networkErrorToAppError,
sync: ListUtils.EventTransformers.syncToClearError
};
ListUtils.mapEvents(this.options.collection, eventTransformers, this);
ListUtils.mapEvents(this.options.courseMetadata, eventTransformers, this);
},
templateHelpers: function() {
return {
controlsLabel: this.controlsLabel
};
}
});
return ListView;
});
......@@ -9,7 +9,7 @@ define(function(require) {
_ = require('underscore'),
Backgrid = require('backgrid'),
pageHandleTemplate = require('text!learners/roster/templates/page-handle.underscore'),
pageHandleTemplate = require('text!components/generic-list/list/templates/page-handle.underscore'),
PagingFooter;
......@@ -29,17 +29,35 @@ define(function(require) {
initialize: function(options) {
Backgrid.Extension.Paginator.prototype.initialize.call(this, options);
this.options = options || {};
this.appFocusable = $('#' + options.appClass + '-focusable');
this.trackPageEventName = options.trackPageEventName || 'edx.bi.list.paged';
},
render: function() {
var trackingModel = this.options.trackingModel;
var trackingModel = this.options.trackingModel,
appFocusable = this.appFocusable,
trackPageEventName = this.trackPageEventName;
Backgrid.Extension.Paginator.prototype.render.call(this);
// pass the tracking model to the page handles so that they can trigger
// tracking event
// Pass the tracking model to the page handles so that they can trigger tracking event. Also passes the
// focusable div that jumps the user to the top of the page and the tracking event name.
// We have to do it in this awkward way because the pageHandle class cannot access the `this` scope of this
// overall PagingFooter class.
_(this.handles).each(function(handle) {
handle.trackingModel = trackingModel; // eslint-disable-line no-param-reassign
/* eslint-disable no-param-reassign */
handle.trackingModel = trackingModel;
handle.appFocusable = appFocusable;
handle.trackPageEventName = trackPageEventName;
/* eslint-enable no-param-reassign */
});
},
/**
* NOTE: this PageHandle class is a subclass of PagingFooter. The `changePage` function is called internally by
* Backgrid when the page handle is clicked by the user. We add some side-effects to the `changePage` function
* here: sending a tracking event and refocusing the browser to the top of the table. This subclass needs
* variables from the encompassing PagingFooter class in order to perform those side-effects and we pass them
* down from the PagingFooter in its render function above.
*/
pageHandle: Backgrid.Extension.PageHandle.extend({
template: _.template(pageHandleTemplate),
trackingModel: undefined, // set by PagingFooter
......@@ -72,11 +90,11 @@ define(function(require) {
changePage: function() {
Backgrid.Extension.PageHandle.prototype.changePage.apply(this, arguments);
if (!this.$el.hasClass('active') && !this.$el.hasClass('disabled')) {
$('#learner-app-focusable').focus();
this.appFocusable.focus();
} else {
this.$('a').focus();
}
this.trackingModel.trigger('segment:track', 'edx.bi.roster.paged', {
this.trackingModel.trigger('segment:track', this.trackPageEventName, {
category: this.pageIndex
});
}
......
/**
* Displays a table of items and a pagination control.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Backgrid = require('backgrid'),
Marionette = require('marionette'),
BaseHeaderCell = require('./base-header-cell'),
PagingFooter = require('./paging-footer'),
listTableTemplate = require('text!components/generic-list/list/templates/table.underscore'),
ListTableView;
ListTableView = Marionette.LayoutView.extend({
template: _.template(listTableTemplate),
regions: {
table: '.list-table',
paginator: '.list-paging-footer'
},
initialize: function(options) {
this.options = _.defaults({}, options, {
tableName: gettext('Generic List'),
trackSubject: 'list',
appClass: ''
});
this.options = _.defaults(this.options, {
trackSortEventName: ['edx', 'bi', this.options.trackSubject, 'sorted'].join('.'),
trackPageEventName: ['edx', 'bi', this.options.trackSubject, 'paged'].join('.')
});
this.collection.on('backgrid:sort', this.onSort, this);
},
onSort: function(column, direction) {
this.options.trackingModel.trigger('segment:track', this.options.trackSortEventName, {
category: column.get('name') + '_' + direction.slice(0, -6)
});
},
onBeforeShow: function() {
this.showChildView('table', new Backgrid.Grid({
className: 'table table-striped dataTable', // Combine bootstrap and datatables styling
collection: this.options.collection,
columns: this.buildColumns()
}));
this.showChildView('paginator', new PagingFooter({
collection: this.options.collection,
trackingModel: this.options.trackingModel,
appClass: this.options.appClass,
trackPageEventName: this.options.trackPageEventName
}));
// Accessibility hacks
this.$('table').prepend('<caption class="sr-only">' + this.options.tableName + '</caption>');
},
/**
* Returns default column settings.
*/
createDefaultColumn: function(label, name) {
return {
label: label,
name: name,
editable: false,
sortable: true,
sortType: 'toggle',
sortValue: function(model, colName) {
var sortVal = model.get(colName);
if (sortVal === null || sortVal === undefined || sortVal === '') {
// Force null values to the end of the ascending sorted list
// NOTE: only works for sorting string value columns
return 'z';
} else {
return 'a ' + sortVal;
}
},
headerCell: BaseHeaderCell,
cell: 'string'
};
},
/**
* Returns column formats for backgrid. See course-list and roster tables
* for examples of usage.
*
* Use createDefaultColumn for standard column settings.
*/
buildColumns: function() {
throw 'Not implemented'; // eslint-disable-line no-throw-literal
}
});
return ListTableView;
});
......@@ -3,7 +3,7 @@ define(function(require) {
var Backbone = require('backbone'),
HeaderView = require('learners/app/views/header');
HeaderView = require('components/header/views/header');
describe('HeaderView', function() {
var fixture;
......
......@@ -7,7 +7,7 @@ define(function(require) {
var _ = require('underscore'),
Marionette = require('marionette'),
headerTemplate = require('text!learners/app/templates/header.underscore'),
headerTemplate = require('text!components/header/templates/header.underscore'),
HeaderView;
......
......@@ -4,7 +4,7 @@ define(function(require) {
var _ = require('underscore'),
Backbone = require('backbone'),
LoadingView = require('learners/common/views/loading-view');
LoadingView = require('components/loading/views/loading-view');
describe('LoadingView', function() {
var fixtureClass = '.loading-fixture';
......
......@@ -3,18 +3,19 @@ define(function(require) {
var Backbone = require('backbone'),
Marionette = require('marionette'),
LearnersRootView = require('learners/app/views/root'),
PageModel = require('learners/common/models/page');
RootView = require('components/root/views/root'),
PageModel = require('components/generic-list/common/models/page');
describe('LearnersRootView', function() {
describe('RootView', function() {
beforeEach(function() {
setFixtures('<div class=root-view-container></div>');
this.rootView = new LearnersRootView({
this.rootView = new RootView({
el: '.root-view-container',
pageModel: new PageModel({
title: 'Testing Title',
lastUpdated: new Date(2016, 1, 28)
})
}),
appClass: 'test'
}).render();
});
......@@ -24,21 +25,21 @@ define(function(require) {
this.$el.html('example view');
}
}))());
expect(this.rootView.$('.learners-main-region').html()).toContainText('example view');
expect(this.rootView.$('.test-main-region').html()).toContainText('example view');
});
it('renders a header title and date', function() {
expect(this.rootView.$('.learners-header-region').html()).toContainText('Testing Title');
expect(this.rootView.$('.learners-header-region').html()).not.toContainText('February 28, 2016');
expect(this.rootView.$('.test-header-region').html()).toContainText('Testing Title');
expect(this.rootView.$('.test-header-region').html()).not.toContainText('February 28, 2016');
});
it('renders and clears alerts', function() {
var childView = new Marionette.View();
this.rootView.showChildView('main', childView);
childView.triggerMethod('appError', {title: 'This is the error copy'});
expect(this.rootView.$('.learners-alert-region')).toHaveText('This is the error copy');
expect(this.rootView.$('.test-alert-region')).toHaveText('This is the error copy');
this.rootView.triggerMethod('clearError', 'This is the error copy');
expect(this.rootView.$('.learners-alert-region')).not.toHaveText('This is the error copy');
expect(this.rootView.$('.test-alert-region')).not.toHaveText('This is the error copy');
});
});
});
<div id="<%- appClass %>-focusable" tabindex="-1"></div>
<div class="<%- appClass %>-navigation-region row"></div>
<div class="<%- appClass %>-header-region row"></div>
<div class="<%- appClass %>-alert-region row"></div>
<div class="<%- appClass %>-main-region row"></div>
/**
* A layout view to manage app page rendering.
*
* Options:
* - pageModel: PageModel object
* - appClass: CSS class to prepend in root template HTML
*/
define(function(require) {
'use strict';
......@@ -7,20 +11,26 @@ define(function(require) {
var _ = require('underscore'),
Marionette = require('marionette'),
AlertView = require('learners/common/views/alert-view'),
HeaderView = require('learners/app/views/header'),
rootTemplate = require('text!learners/app/templates/root.underscore'),
AlertView = require('components/alert/views/alert-view'),
HeaderView = require('components/header/views/header'),
rootTemplate = require('text!components/root/templates/root.underscore'),
LearnersRootView;
RootView;
LearnersRootView = Marionette.LayoutView.extend({
RootView = Marionette.LayoutView.extend({
template: _.template(rootTemplate),
regions: {
alert: '.learners-alert-region',
header: '.learners-header-region',
main: '.learners-main-region',
navigation: '.learners-navigation-region'
templateHelpers: function() {
return this.options;
},
regions: function(options) {
return {
alert: _.template('.<%= appClass %>-alert-region')(options),
header: _.template('.<%= appClass %>-header-region')(options),
main: _.template('.<%= appClass %>-main-region')(options),
navigation: _.template('.<%= appClass %>-navigation-region')(options)
};
},
childEvents: {
......@@ -31,13 +41,15 @@ define(function(require) {
},
initialize: function(options) {
this.options = options || {};
this.options = _.defaults({displayHeader: true}, options);
},
onRender: function() {
this.showChildView('header', new HeaderView({
model: this.options.pageModel
}));
if (this.options.displayHeader) {
this.showChildView('header', new HeaderView({
model: this.options.pageModel
}));
}
},
onAppError: function(childView, options) {
......@@ -79,5 +91,5 @@ define(function(require) {
}
});
return LearnersRootView;
return RootView;
});
define(function(require) {
'use strict';
var $ = require('jquery'),
SkipLinkView = require('components/skip-link/views/skip-link-view');
describe('SkipLinkView', function() {
it('sets focus when clicked', function() {
var view = new SkipLinkView({
el: 'body',
template: false
});
setFixtures('<a href="#content" class="skip-link">Testing</a><div id="content">a div</div>');
view.render();
// because it's difficult to test that element has been scrolled to, test check that
// the method has been called
spyOn($('#content')[0], 'scrollIntoView').and.callThrough();
expect($('#content')[0]).not.toBe(document.activeElement);
$('.skip-link').click();
expect($('#content')[0]).toBe(document.activeElement);
expect($('#content')[0].scrollIntoView).toHaveBeenCalled();
});
});
});
/**
* This view sets the focus the #content DOM element and scrolls to it. It's
* expected that the elements exist on the page already and the skip link has
* class "skip-link" and the content has ID "content".
*
* The element (e.g. "el" attribute) for this view will need to have both the
* skip link and the main content in it's scope and will most likely be the
* body element.
*/
define(function(require) {
'use strict';
var Marionette = require('marionette');
return Marionette.ItemView.extend({
template: false,
ui: {
skipLink: '.skip-link',
content: '#content'
},
events: {
'click @ui.skipLink': 'clicked'
},
onRender: function() {
// enables content to be focusable
this.ui.content.attr('tabindex', -1);
},
clicked: function(e) {
this.ui.content.focus();
this.ui.content[0].scrollIntoView();
e.preventDefault();
}
});
});
......@@ -3,9 +3,9 @@ define(function(require) {
var $ = require('jquery'),
LearnerUtils = require('learners/common/utils');
ListUtils = require('components/utils/utils');
describe('LearnerUtils', function() {
describe('ListUtils', function() {
describe('handleAjaxFailure', function() {
var server;
......@@ -23,7 +23,7 @@ define(function(require) {
var spy = {trigger: function() {}};
spyOn(spy, 'trigger');
$.ajax('/resource/')
.fail(LearnerUtils.handleAjaxFailure.bind(spy))
.fail(ListUtils.handleAjaxFailure.bind(spy))
.always(function() {
expect(spy.trigger).not.toHaveBeenCalled();
done();
......@@ -36,7 +36,7 @@ define(function(require) {
spy = {trigger: function() {}};
spyOn(spy, 'trigger');
$.ajax({url: '/resource/', dataType: 'json'})
.fail(LearnerUtils.handleAjaxFailure.bind(spy))
.fail(ListUtils.handleAjaxFailure.bind(spy))
.always(function() {
expect(spy.trigger).toHaveBeenCalledWith('serverError', 500, fakeServerResponse);
done();
......@@ -48,7 +48,7 @@ define(function(require) {
var spy = {trigger: function() {}};
spyOn(spy, 'trigger');
$.ajax({url: '/resource/', timeout: 1})
.fail(LearnerUtils.handleAjaxFailure.bind(spy))
.fail(ListUtils.handleAjaxFailure.bind(spy))
.always(function() {
expect(spy.trigger).toHaveBeenCalledWith('networkError', 'timeout');
done();
......
define(function(require) {
'use strict';
var $ = require('jquery'),
Backbone = require('backbone'),
Marionette = require('marionette'),
_ = require('underscore'),
initModels = require('load/init-page'),
CourseListCollection = require('course-list/common/collections/course-list'),
CourseListController = require('course-list/app/controller'),
CourseListRootView = require('components/root/views/root'),
CourseListRouter = require('course-list/app/router'),
PageModel = require('components/generic-list/common/models/page'),
SkipLinkView = require('components/skip-link/views/skip-link-view'),
CourseListApp;
CourseListApp = Marionette.Application.extend({
/**
* Initializes the course-list analytics app.
*/
initialize: function(options) {
this.options = options || {};
},
onStart: function() {
var pageModel = new PageModel(),
courseListCollection,
rootView;
new SkipLinkView({
el: 'body'
}).render();
courseListCollection = new CourseListCollection(this.options.courseListJson, {
downloadUrl: this.options.courseListDownloadUrl,
mode: 'client'
});
rootView = new CourseListRootView({
el: $(this.options.containerSelector),
pageModel: pageModel,
appClass: 'course-list',
displayHeader: false
}).render();
new CourseListRouter({ // eslint-disable-line no-new
controller: new CourseListController({
courseListCollection: courseListCollection,
hasData: _.isObject(this.options.courseListJson),
pageModel: pageModel,
rootView: rootView,
trackingModel: initModels.models.trackingModel
})
});
Backbone.history.start();
}
});
return CourseListApp;
});
/**
* Controller object for the course list application. Handles business
* logic of showing different 'pages' of the application.
*
* Requires the following values in the options hash:
* - CourseListCollection: A `CourseListCollection` instance.
* - rootView: A `CourseListRootView` instance.
*/
define(function(require) {
'use strict';
var Backbone = require('backbone'),
Marionette = require('marionette'),
CourseListView = require('course-list/list/views/course-list'),
CourseListController;
CourseListController = Marionette.Object.extend({
initialize: function(options) {
this.options = options || {};
this.listenTo(this.options.courseListCollection, 'sync', this.onCourseListCollectionUpdated);
this.onCourseListCollectionUpdated(this.options.courseListCollection);
},
/**
* Event handler for the 'showPage' event. Called by the
* router whenever a route method beginning with "show" has
* been triggered. Executes before the route method does.
*/
onShowPage: function() {
// Clear any existing alert
this.options.rootView.triggerMethod('clearError');
},
onCourseListCollectionUpdated: function(collection) {
// Note that we currently assume that all the courses in
// the list were last updated at the same time.
if (collection.length) {
this.options.pageModel.set('lastUpdated', collection.at(0).get('last_updated'));
}
},
showCourseListPage: function(queryString) {
var listView = new CourseListView({
collection: this.options.courseListCollection,
hasData: this.options.hasData,
tableName: gettext('Course List'),
trackSubject: 'course_list',
appClass: 'course-list',
trackingModel: this.options.trackingModel
}),
collection = this.options.courseListCollection,
currentPage;
try {
collection.setStateFromQueryString(queryString);
this.options.rootView.showChildView('main', listView);
if (collection.isStale) {
// There was a querystring sort parameter that was different from the default collection sorting, so
// we have to sort the table.
// We don't just do collection.fullCollection.sort() here because we've attached custom sortValue
// options to the columns via Backgrid to handle null values and we must call the sort function on
// the Backgrid table object for those custom sortValues to have an effect.
// Also, for some unknown reason, the Backgrid sort overwrites the currentPage, so we will save and
// restore the currentPage after the sort completes.
currentPage = collection.state.currentPage;
listView.getRegion('results').currentView
.getRegion('main').currentView.table.currentView
.sort(collection.state.sortKey,
collection.state.order === 1 ? 'descending' : 'ascending');
// Not using collection.setPage() here because it appears to have a bug.
// This about the same as what setPage() does internally.
collection.getPage(currentPage - (1 - collection.state.firstPage), {reset: true});
collection.isStale = false;
}
} catch (e) {
// These JS errors occur when trying to parse invalid URL parameters
// FIXME: they also catch a whole lot of other kinds of errors where the alert message doesn't make much
// sense.
if (e instanceof RangeError || e instanceof TypeError) {
this.options.rootView.showAlert('error', gettext('Invalid Parameters'),
gettext('Sorry, we couldn\'t find any courses that matched that query.'),
{url: '#', text: gettext('Return to the Course List page.')});
} else {
throw e;
}
}
this.options.rootView.getRegion('navigation').empty();
this.options.pageModel.set('title', gettext('Course List'));
this.onCourseListCollectionUpdated(collection);
collection.trigger('loaded');
// track the "page" view
this.options.trackingModel.set('page', {
scope: 'insights',
lens: 'home',
report: '',
depth: '',
name: 'insights_home'
});
this.options.trackingModel.trigger('segment:page');
return listView;
},
showNotFoundPage: function() {
var message = gettext("Sorry, we couldn't find the page you're looking for."),
notFoundView;
this.options.pageModel.set('title', gettext('Page Not Found'));
notFoundView = new (Backbone.View.extend({
render: function() {
this.$el.text(message);
return this;
}
}))();
this.options.rootView.showChildView('main', notFoundView);
// track the "page" view
this.options.trackingModel.set('page', {
scope: 'insights',
lens: 'home',
report: 'not_found',
depth: '',
name: 'insights_home_not_found'
});
this.options.trackingModel.trigger('segment:page');
}
});
return CourseListController;
});
require(['vendor/domReady!', 'jquery', 'load/init-page',
'apps/course-list/app/app'], function(doc, $, page, CourseListApp) {
'use strict';
var modelData = page.models.courseModel,
app = new CourseListApp({
containerSelector: '.course-list-app-container',
courseListJson: modelData.get('course_list_json'),
courseListDownloadUrl: modelData.get('course_list_download_url')
});
app.start();
});
define(function(require) {
'use strict';
var Marionette = require('marionette'),
CourseListRouter;
CourseListRouter = Marionette.AppRouter.extend({
// Routes intended to show a page in the app should map to method names
// beginning with "show", e.g. 'showCourseListPage'.
appRoutes: {
'(/)(?*queryString)': 'showCourseListPage',
'*notFound': 'showNotFoundPage'
},
// 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.courseListCollection = options.controller.options.courseListCollection;
this.listenTo(this.courseListCollection, 'loaded', this.updateUrl);
this.listenTo(this.courseListCollection, 'backgrid:refresh', this.updateUrl);
Marionette.AppRouter.prototype.initialize.call(this, options);
},
// Called on CourseListCollection 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.courseListCollection.getQueryString(), {replace: true, trigger: false});
}
});
return CourseListRouter;
});
define(function(require) {
'use strict';
var CourseListCollection = require('course-list/common/collections/course-list'),
CourseListController = require('course-list/app/controller'),
RootView = require('components/root/views/root'),
PageModel = require('components/generic-list/common/models/page'),
TrackingModel = require('models/tracking-model'),
expectCourseListPage,
fakeCourse;
describe('CourseListController', function() {
// convenience method for asserting that we are on the course list page
expectCourseListPage = function(controller) {
expect(controller.options.rootView.$('.course-list')).toBeInDOM();
expect(controller.options.rootView.$('.course-list-header-region').html()).toContainText('Course List');
};
fakeCourse = function(id, name) {
var count = parseInt(Math.random() * (150) + 50, 10);
return {
course_id: id,
catalog_course_title: name,
catalog_course: name,
start_date: '2017-01-01',
end_date: '2017-04-01',
pacing_type: Math.random() > 0.5 ? 'instructor_paced' : 'self_paced',
count: count,
cumulative_count: parseInt(count + (count / 2), 10),
enrollment_modes: {
audit: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
credit: {
count: count,
cumulative_count: parseInt(count + (count / 2), 10),
count_change_7_days: 5
},
verified: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
honor: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
professional: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
}
},
created: '',
availability: '',
count_change_7_days: 0,
verified_enrollment: 0
};
};
beforeEach(function() {
var pageModel = new PageModel();
setFixtures('<div class="root-view"><div class="main"></div></div>');
this.rootView = new RootView({
el: '.root-view',
pageModel: pageModel,
appClass: 'course-list'
});
this.rootView.render();
this.course = fakeCourse('course1', 'course');
this.collection = new CourseListCollection([this.course], {mode: 'client'});
this.controller = new CourseListController({
rootView: this.rootView,
courseListCollection: this.collection,
hasData: true,
pageModel: pageModel,
trackingModel: new TrackingModel()
});
});
it('should show the course list page', function() {
this.controller.showCourseListPage();
expectCourseListPage(this.controller);
});
it('should show invalid parameters alert with invalid URL parameters', function() {
this.controller.showCourseListPage('text_search=foo=');
expect(this.controller.options.rootView.$('.course-list-alert-region').html()).toContainText(
'Invalid Parameters'
);
expect(this.controller.options.rootView.$('.course-list-main-region').html()).toBe('');
});
it('should show the not found page', function() {
this.controller.showNotFoundPage();
// eslint-disable-next-line max-len
expect(this.rootView.$el.html()).toContainText("Sorry, we couldn't find the page you're looking for.");
});
it('should sort the list with sort parameters', function() {
var secondCourse = fakeCourse('course2', 'Another Course');
this.collection.add(secondCourse);
this.controller.showCourseListPage('sortKey=catalog_course_title&order=asc');
expect(this.collection.at(0).toJSON()).toEqual(secondCourse);
});
});
});
define(function(require) {
'use strict';
var Backbone = require('backbone'),
CourseListCollection = require('course-list/common/collections/course-list'),
CourseListController = require('course-list/app/controller'),
CourseListRouter = require('course-list/app/router'),
PageModel = require('components/generic-list/common/models/page');
describe('CourseListRouter', function() {
beforeEach(function() {
Backbone.history.start({silent: true});
this.course = {
last_updated: new Date(2016, 1, 28)
};
this.collection = new CourseListCollection([this.course]);
this.controller = new CourseListController({
courseListCollection: this.collection,
pageModel: new PageModel()
});
spyOn(this.controller, 'showCourseListPage').and.stub();
spyOn(this.controller, 'showNotFoundPage').and.stub();
spyOn(this.controller, 'onShowPage').and.stub();
this.router = new CourseListRouter({
controller: this.controller
});
});
afterEach(function() {
// Clear previous route
this.router.navigate('');
Backbone.history.stop();
});
it('triggers a showPage event for pages beginning with "show"', function() {
this.router.navigate('foo', {trigger: true});
expect(this.controller.onShowPage).toHaveBeenCalled();
this.router.navigate('/', {trigger: true});
expect(this.controller.onShowPage).toHaveBeenCalled();
});
describe('showCourseListPage', function() {
beforeEach(function() {
// Backbone won't trigger a route unless we were on a previous url
this.router.navigate('initial-fragment', {trigger: false});
});
it('should trigger on an empty URL fragment', function() {
this.router.navigate('', {trigger: true});
expect(this.controller.showCourseListPage).toHaveBeenCalled();
});
it('should trigger on a single forward slash', function() {
this.router.navigate('/', {trigger: true});
expect(this.controller.showCourseListPage).toHaveBeenCalled();
});
it('should trigger on a URL fragment with a querystring', function() {
var querystring = 'text_search=some_course';
this.router.navigate('?' + querystring, {trigger: true});
expect(this.controller.showCourseListPage).toHaveBeenCalledWith(querystring, null);
});
});
describe('showNotFoundPage', function() {
it('should trigger on unmatched URLs', function() {
this.router.navigate('this/does/not/match', {trigger: true});
expect(this.controller.showNotFoundPage).toHaveBeenCalledWith('this/does/not/match', null);
});
});
it('URL fragment is updated on CourseListCollection loaded', function(done) {
this.collection.state.currentPage = 2;
this.collection.once('loaded', function() {
expect(Backbone.history.getFragment()).toBe('?sortKey=count&order=desc&page=2');
done();
});
this.collection.trigger('loaded');
});
it('URL fragment is updated on CourseListCollection refresh', function(done) {
this.collection.state.currentPage = 2;
this.collection.once('backgrid:refresh', function() {
expect(Backbone.history.getFragment()).toBe('?sortKey=count&order=desc&page=2');
done();
});
this.collection.trigger('backgrid:refresh');
});
});
});
define(function(require) {
'use strict';
var ListCollection = require('components/generic-list/common/collections/collection'),
CourseModel = require('course-list/common/models/course'),
CourseListCollection;
CourseListCollection = ListCollection.extend({
model: CourseModel,
initialize: function(models, options) {
ListCollection.prototype.initialize.call(this, models, options);
this.registerSortableField('catalog_course_title', gettext('Course Name'));
this.registerSortableField('start_date', gettext('Start Date'));
this.registerSortableField('end_date', gettext('End Date'));
this.registerSortableField('cumulative_count', gettext('Total Enrollment'));
this.registerSortableField('count', gettext('Current Enrollment'));
this.registerSortableField('count_change_7_days', gettext('Change Last Week'));
this.registerSortableField('verified_enrollment', gettext('Verified Enrollment'));
this.registerFilterableField('availability', gettext('Availability'));
this.registerFilterableField('pacing_type', gettext('Pacing Type'));
},
state: {
pageSize: 100,
sortKey: 'count',
order: 1
}
});
return CourseListCollection;
});
define(function(require) {
'use strict';
var SpecHelpers = require('uitk/utils/spec-helpers/spec-helpers'),
CourseModel = require('course-list/common/models/course'),
CourseList = require('course-list/common/collections/course-list');
describe('CourseList', function() {
var courseList;
beforeEach(function() {
var courses = [
new CourseModel({
catalog_course_title: 'Alpaca',
course_id: 'Alpaca',
count: 10,
cumulative_count: 20,
count_change_7_days: 30,
verified_enrollment: 40
}),
new CourseModel({
catalog_course_title: 'zebra',
course_id: 'zebra',
count: 0,
cumulative_count: 1000,
count_change_7_days: -10,
verified_enrollment: 1000
})
];
courseList = new CourseList(courses, {mode: 'client'});
});
describe('registered sort field', function() {
SpecHelpers.withConfiguration({
catalog_course_title: [
'catalog_course_title', // field name
'Course Name' // expected display name
],
start_date: [
'start_date', // field name
'Start Date' // expected display name
],
end_date: [
'end_date', // field name
'End Date' // expected display name
],
cumulative_count: [
'cumulative_count', // field name
'Total Enrollment' // expected display name
],
count: [
'count', // field name
'Current Enrollment' // expected display name
],
count_change_7_days: [
'count_change_7_days', // field name
'Change Last Week' // expected display name
],
verified_enrollment: [
'verified_enrollment', // field name
'Verified Enrollment' // expected display name
]
}, function(sortField, expectedResults) {
this.sortField = sortField;
this.expectedResults = expectedResults;
}, function() {
it('displays name', function() {
courseList.setSorting(this.sortField);
expect(courseList.sortDisplayName()).toEqual(this.expectedResults);
});
});
});
});
});
define(function(require) {
'use strict';
var _ = require('underscore'),
Backbone = require('backbone'),
CourseModel;
CourseModel = Backbone.Model.extend({
defaults: function() {
return {
created: '',
course_id: '',
catalog_course_title: '',
catalog_course: '',
start_date: '',
end_date: '',
pacing_type: '',
availability: '',
count: 0,
cumulative_count: 0,
count_change_7_days: 0,
verified_enrollment: 0,
enrollment_modes: {
audit: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
credit: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
verified: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
honor: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
professional: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
}
}
};
},
/**
* Backgrid will only work on models that are one level deep, so we must flatten the data structure to access
* the verified enrollment count from the table.
*/
initialize: function() {
this.set({verified_enrollment: this.get('enrollment_modes').verified.count});
},
idAttribute: 'course_id',
/**
* Returns true if the course_id has been set. False otherwise.
*/
hasData: function() {
return !_(this.get('course_id')).isEmpty();
}
});
return CourseModel;
});
define(function(require) {
'use strict';
var CourseModel = require('course-list/common/models/course');
describe('CourseModel', function() {
it('should have all the expected fields', function() {
var course = new CourseModel();
expect(course.attributes).toEqual({
created: '',
course_id: '',
catalog_course_title: '',
catalog_course: '',
start_date: '',
end_date: '',
pacing_type: '',
availability: '',
count: 0,
cumulative_count: 0,
count_change_7_days: 0,
verified_enrollment: 0,
enrollment_modes: {
audit: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
credit: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
verified: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
honor: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
},
professional: {
count: 0,
cumulative_count: 0,
count_change_7_days: 0
}
}
});
});
it('should populate verified_enrollment from the verified count', function() {
var learner = new CourseModel({
enrollment_modes: {
verified: {
count: 90210
}
}
});
expect(learner.get('verified_enrollment')).toEqual(90210);
});
it('should use course_id to determine if data is available', function() {
var course = new CourseModel();
expect(course.hasData()).toBe(false);
course.set('course_id', 'edx/demo/course');
expect(course.hasData()).toBe(true);
});
});
});
<a href="/courses/<%- course_id %>">
<div class="course-name">
<%- catalog_course_title %>
</div>
<div class="course-id">
<%- course_id %>
</div>
</a>
<div class="row">
<div class="col col-12 sm-col-12 md-col-8">
<div class="course-list-active-filters"></div>
</div>
<div class="col col-12 sm-col-12 md-col-4">
<div class="activity-date-range small"></div>
</div>
</div>
<div class="row">
<div class="col col-12 course-list-results-col">
<div class="course-list-download-data"></div>
<div class="course-list-results"></div>
</div>
</div>
</div>
<div class="section-data-table">
<div class="course-list-table table-responsive"></div>
<div class="course-list-paging-footer"></div>
</div>
define(function(require) {
'use strict';
var BaseHeaderCell = require('components/generic-list/list/views/base-header-cell'),
CourseListBaseHeaderCell;
CourseListBaseHeaderCell = BaseHeaderCell.extend({
container: '.course-list-table'
});
return CourseListBaseHeaderCell;
});
/**
* Cell class which combines course id and name. The name links
* to the course home page.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Backgrid = require('backgrid'),
courseIdAndNameCellTemplate = require('text!course-list/list/templates/course-id-and-name-cell.underscore'),
CourseIdAndNameCell;
CourseIdAndNameCell = Backgrid.Cell.extend({
className: 'course-name-cell',
template: _.template(courseIdAndNameCellTemplate),
render: function() {
this.$el.html(this.template(this.model.toJSON()));
return this;
}
});
return CourseIdAndNameCell;
});
/**
* Renders a sortable, filterable, and searchable paginated table of
* courses for the Course List app.
*
* Requires the following values in the options hash:
* - options.collection - an instance of CourseListCollection
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
CourseListResultsView = require('course-list/list/views/results'),
ListView = require('components/generic-list/list/views/list'),
listTemplate = require('text!course-list/list/templates/list.underscore'),
CourseListView;
CourseListView = ListView.extend({
className: 'course-list',
template: _.template(listTemplate),
regions: {
results: '.course-list-results'
},
initialize: function(options) {
ListView.prototype.initialize.call(this, options);
this.childViews = [
{
region: 'results',
class: CourseListResultsView,
options: {
collection: this.options.collection,
hasData: this.options.hasData,
tableName: this.options.tableName,
trackingModel: this.options.trackingModel,
trackSubject: this.options.trackSubject,
appClass: this.options.appClass
}
}
];
this.controlsLabel = gettext('Course list controls');
}
});
return CourseListView;
});
/**
* Displays either a paginated table of courses or a message that there are
* no courses to display.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Marionette = require('marionette'),
AlertView = require('components/alert/views/alert-view'),
CourseListTableView = require('course-list/list/views/table'),
CourseListResultsView;
CourseListResultsView = Marionette.LayoutView.extend({
template: _.template('<div class="list-main"></div>'),
regions: {
main: '.list-main'
},
initialize: function(options) {
this.options = options || {};
// Unlike the 'sync' event, the backgrid:refresh event sends an object with the collection inside. It's
// necessary to extract the collection and pass that to the onCourseListCollectionUpdated function for
// it to work properly.
this.listenTo(this.options.collection, 'backgrid:refresh', _.bind(function(eventObject) {
this.onCourseListCollectionUpdated(eventObject.collection);
}, this));
},
onBeforeShow: function() {
this.onCourseListCollectionUpdated(this.options.collection);
},
onCourseListCollectionUpdated: function(collection) {
if (collection.length && this.options.hasData) {
// Don't re-render the courses table view if one already exists.
if (!(this.getRegion('main').currentView instanceof CourseListTableView)) {
this.showChildView('main', new CourseListTableView(_.defaults({
collection: collection
}, this.options)));
}
} else {
this.showChildView('main', this.createAlertView(collection));
}
},
createAlertView: function(collection) {
var hasSearch = collection.hasActiveSearch(),
hasActiveFilter = !_.isEmpty(collection.getActiveFilterFields()),
suggestions = [],
noCoursesMessage,
detailedMessage;
if (hasSearch || hasActiveFilter) {
noCoursesMessage = gettext('No courses matched your criteria.');
if (hasSearch) {
suggestions.push(gettext('Try a different search.'));
}
if (hasActiveFilter) {
suggestions.push(gettext('Try clearing the filters.'));
}
} else {
noCoursesMessage = gettext('No course data is currently available for your course.');
// eslint-disable-next-line max-len
detailedMessage = gettext('No courses are enrolled, or course activity data has not yet been processed. Data is updated every day, so check back regularly for up-to-date metrics.');
}
return new AlertView({
alertType: 'info',
title: noCoursesMessage,
body: detailedMessage,
suggestions: suggestions
});
}
});
return CourseListResultsView;
});
/**
* Displays a table of courses and a pagination control.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Backgrid = require('backgrid'),
ListTableView = require('components/generic-list/list/views/table'),
CourseIdAndNameCell = require('course-list/list/views/course-id-and-name-cell'),
courseListTableTemplate = require('text!course-list/list/templates/table.underscore'),
Utils = require('utils/utils'),
INTEGER_COLUMNS = ['count', 'cumulative_count', 'count_change_7_days', 'verified_enrollment'],
DATE_COLUMNS = ['start_date', 'end_date'],
CourseListTableView;
// This attached to Backgrid.Extensions.MomentCell
require('backgrid-moment-cell');
CourseListTableView = ListTableView.extend({
template: _.template(courseListTableTemplate),
regions: {
table: '.course-list-table',
paginator: '.course-list-paging-footer'
},
buildColumns: function() {
return _.map(this.options.collection.sortableFields, function(val, key) {
var column = this.createDefaultColumn(val.displayName, key);
if (INTEGER_COLUMNS.indexOf(key) !== -1) {
column.cell = 'integer';
column.sortValue = key; // reset to normal sorting for integer columns
} else if (DATE_COLUMNS.indexOf(key) !== -1) {
column.cell = Backgrid.Extension.MomentCell.extend({
displayLang: Utils.getMomentLocale(),
displayFormat: 'L',
render: function() {
var result = Backgrid.Extension.MomentCell.prototype.render.call(this, arguments);
// Null values are rendered by MomentCell as "Invalid date". Convert to a nicer string:
if (result.el.textContent === 'Invalid date') {
result.el.textContent = '--';
$(result.el).attr('aria-label', gettext('Date not available'));
}
return result;
}
});
} else if (key === 'catalog_course_title') {
column.cell = CourseIdAndNameCell;
} else {
column.cell = 'string';
}
return column;
}, this);
}
});
return CourseListTableView;
});
......@@ -12,9 +12,10 @@ define(function(require) {
CourseMetadataModel = require('learners/common/models/course-metadata'),
LearnerCollection = require('learners/common/collections/learners'),
LearnersController = require('learners/app/controller'),
LearnersRootView = require('learners/app/views/root'),
LearnersRootView = require('components/root/views/root'),
LearnersRouter = require('learners/app/router'),
PageModel = require('learners/common/models/page'),
PageModel = require('components/generic-list/common/models/page'),
SkipLinkView = require('components/skip-link/views/skip-link-view'),
LearnersApp;
......@@ -55,6 +56,10 @@ define(function(require) {
learnerCollection,
rootView;
new SkipLinkView({
el: 'body'
}).render();
learnerCollection = new LearnerCollection(this.options.learnerListJson, {
url: this.options.learnerListUrl,
downloadUrl: this.options.learnerListDownloadUrl,
......@@ -69,7 +74,8 @@ define(function(require) {
rootView = new LearnersRootView({
el: $(this.options.containerSelector),
pageModel: pageModel
pageModel: pageModel,
appClass: 'learners'
}).render();
new LearnersRouter({ // eslint-disable-line no-new
......
......@@ -18,10 +18,10 @@ 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'),
LoadingView = require('components/loading/views/loading-view'),
ReturnLinkView = require('learners/detail/views/learner-return'),
rosterLoadingTemplate = require('text!learners/roster/templates/roster-loading.underscore'),
rosterLoadingTemplate = require('text!components/loading/templates/plain-loading.underscore'),
LearnersController;
......@@ -56,6 +56,9 @@ define(function(require) {
var rosterView = new LearnerRosterView({
collection: this.options.learnerCollection,
courseMetadata: this.options.courseMetadata,
tableName: gettext('Learner Roster'),
trackSubject: 'roster',
appClass: 'learners',
hasData: this.options.hasData,
trackingModel: this.options.trackingModel
}),
......
......@@ -6,8 +6,8 @@ define(function(require) {
CourseMetadataModel = require('learners/common/models/course-metadata'),
LearnerCollection = require('learners/common/collections/learners'),
LearnersController = require('learners/app/controller'),
LearnersRootView = require('learners/app/views/root'),
PageModel = require('learners/common/models/page'),
RootView = require('components/root/views/root'),
PageModel = require('components/generic-list/common/models/page'),
TrackingModel = require('models/tracking-model');
describe('LearnersController', function() {
......@@ -44,9 +44,10 @@ define(function(require) {
server = sinon.fakeServer.create();
setFixtures('<div class="root-view"><div class="main"></div></div>');
this.rootView = new LearnersRootView({
this.rootView = new RootView({
el: '.root-view',
pageModel: pageModel
pageModel: pageModel,
appClass: 'learners'
});
this.rootView.render();
// The learner roster view looks at the first learner in
......
......@@ -5,7 +5,7 @@ define(function(require) {
LearnerCollection = require('learners/common/collections/learners'),
LearnersController = require('learners/app/controller'),
LearnersRouter = require('learners/app/router'),
PageModel = require('learners/common/models/page');
PageModel = require('components/generic-list/common/models/page');
describe('LearnersRouter', function() {
beforeEach(function() {
......
<div id="learner-app-focusable" tabindex="-1"></div>
<div class="learners-navigation-region row"></div>
<div class="learners-header-region row"></div>
<div class="learners-alert-region row"></div>
<div class="learners-main-region row"></div>
define(function(require) {
'use strict';
var PagingCollection = require('uitk/pagination/paging-collection'),
var ListCollection = require('components/generic-list/common/collections/collection'),
LearnerModel = require('learners/common/models/learner'),
LearnerUtils = require('learners/common/utils'),
Utils = require('utils/utils'),
_ = require('underscore'),
LearnerCollection;
LearnerCollection = PagingCollection.extend({
LearnerCollection = ListCollection.extend({
model: LearnerModel,
initialize: function(models, options) {
PagingCollection.prototype.initialize.call(this, options);
ListCollection.prototype.initialize.call(this, models, options);
this.url = options.url;
this.downloadUrl = options.downloadUrl;
this.courseId = options.courseId;
this.registerSortableField('username', gettext('Name (Username)'));
......@@ -32,100 +27,8 @@ define(function(require) {
this.registerFilterableField('enrollment_mode', gettext('Enrollment Mode'));
},
fetch: function(options) {
return PagingCollection.prototype.fetch.call(this, options)
.fail(LearnerUtils.handleAjaxFailure.bind(this));
},
state: {
pageSize: 25
},
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();
},
/**
* 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 = [];
// 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});
return Utils.toQueryString(orderedParams);
},
/**
* 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);
}
}
}
});
......
......@@ -4,7 +4,7 @@ define(function(require) {
var _ = require('underscore'),
Backbone = require('backbone'),
LearnerUtils = require('learners/common/utils'),
ListUtils = require('components/utils/utils'),
CourseMetadataModel;
......@@ -57,7 +57,7 @@ define(function(require) {
fetch: function(options) {
return Backbone.Model.prototype.fetch.call(this, options)
.fail(LearnerUtils.handleAjaxFailure.bind(this));
.fail(ListUtils.handleAjaxFailure.bind(this));
},
parse: function(response) {
......
......@@ -3,7 +3,7 @@ define(function(require) {
var Backbone = require('backbone'),
LearnerUtils = require('learners/common/utils'),
ListUtils = require('components/utils/utils'),
EngagementTimelineModel;
......@@ -35,7 +35,7 @@ define(function(require) {
fetch: function() {
return Backbone.Model.prototype.fetch.apply(this, arguments)
.fail(LearnerUtils.handleAjaxFailure.bind(this));
.fail(ListUtils.handleAjaxFailure.bind(this));
},
hasData: function() {
......
......@@ -4,7 +4,7 @@ define(function(require) {
var _ = require('underscore'),
Backbone = require('backbone'),
LearnerUtils = require('learners/common/utils'),
ListUtils = require('components/utils/utils'),
LearnerModel;
......@@ -47,7 +47,7 @@ define(function(require) {
fetch: function(options) {
return Backbone.Model.prototype.fetch.call(this, options)
.fail(LearnerUtils.handleAjaxFailure.bind(this));
.fail(ListUtils.handleAjaxFailure.bind(this));
},
parse: function(response) {
......
......@@ -4,17 +4,17 @@ define(function(require) {
var _ = require('underscore'),
Marionette = require('marionette'),
LearnerUtils = require('learners/common/utils'),
ListUtils = require('components/utils/utils'),
Utils = require('utils/utils'),
AlertView = require('learners/common/views/alert-view'),
AlertView = require('components/alert/views/alert-view'),
LearnerEngagementTableView = require('learners/detail/views/engagement-table'),
LearnerEngagementTimelineView = require('learners/detail/views/engagement-timeline'),
LearnerNameView = require('learners/detail/views/learner-names'),
LearnerSummaryFieldView = require('learners/detail/views/learner-summary-field'),
LoadingView = require('learners/common/views/loading-view'),
chartLoadingTemplate = require('text!learners/detail/templates/chart-loading.underscore'),
tableLoadingTemplate = require('text!learners/detail/templates/table-loading.underscore'),
LoadingView = require('components/loading/views/loading-view'),
chartLoadingTemplate = require('text!components/loading/templates/chart-loading.underscore'),
tableLoadingTemplate = require('text!components/loading/templates/table-loading.underscore'),
learnerDetailTemplate = require('text!learners/detail/templates/learner-detail.underscore');
return Marionette.LayoutView.extend({
......@@ -44,17 +44,17 @@ define(function(require) {
initialize: function(options) {
Marionette.LayoutView.prototype.initialize.call(this, options);
this.options = options || {};
LearnerUtils.mapEvents(this.options.engagementTimelineModel, {
ListUtils.mapEvents(this.options.engagementTimelineModel, {
serverError: this.timelineServerErrorToAppError,
networkError: LearnerUtils.EventTransformers.networkErrorToAppError,
sync: LearnerUtils.EventTransformers.syncToClearError
networkError: ListUtils.EventTransformers.networkErrorToAppError,
sync: ListUtils.EventTransformers.syncToClearError
}, this);
this.listenTo(this, 'engagementTimelineUnavailable', this.showTimelineUnavailable);
LearnerUtils.mapEvents(this.options.learnerModel, {
ListUtils.mapEvents(this.options.learnerModel, {
serverError: this.learnerServerErrorToAppError,
networkError: LearnerUtils.EventTransformers.networkErrorToAppError,
sync: LearnerUtils.EventTransformers.syncToClearError
networkError: ListUtils.EventTransformers.networkErrorToAppError,
sync: ListUtils.EventTransformers.syncToClearError
}, this);
this.listenTo(this, 'learnerUnavailable', this.showLearnerUnavailable);
},
......@@ -139,7 +139,7 @@ define(function(require) {
description: gettext('Check back daily for up-to-date data.')
}];
} else {
return LearnerUtils.EventTransformers.serverErrorToAppError(status);
return ListUtils.EventTransformers.serverErrorToAppError(status);
}
},
......
/**
* Base class for all table header cells. Adds proper routing and icons.
*/
define(function(require) {
'use strict';
var _ = require('underscore'),
Backgrid = require('backgrid'),
var BaseHeaderCell = require('components/generic-list/list/views/base-header-cell'),
baseHeaderCellTemplate = require('text!learners/roster/templates/base-header-cell.underscore'),
LearnersBaseHeaderCell;
BaseHeaderCell,
tooltips;
tooltips = {
username: gettext('The name and username of this learner. Click to sort by username.'),
problems_attempted: gettext('Number of unique problems this learner attempted.'),
problems_completed: gettext('Number of unique problems the learner answered correctly.'),
videos_viewed: gettext('Number of unique videos this learner played.'),
// eslint-disable-next-line max-len
problem_attempts_per_completed: gettext('Average number of attempts per correct problem. Learners with a relatively high value compared to their peers may be struggling.'),
// eslint-disable-next-line max-len
discussion_contributions: gettext('Number of contributions by this learner, including posts, responses, and comments.')
};
BaseHeaderCell = Backgrid.HeaderCell.extend({
attributes: {
scope: 'col'
},
template: _.template(baseHeaderCellTemplate),
initialize: function() {
Backgrid.HeaderCell.prototype.initialize.apply(this, arguments);
this.collection.on('backgrid:sort', this.onSort, this);
// Set up the tooltip
this.$el.attr('title', tooltips[this.column.get('name')]);
this.$el.tooltip({container: '.learners-table'});
},
render: function() {
var directionWord;
if (this.collection.state.sortKey && this.collection.state.sortKey === this.column.attributes.name) {
directionWord = this.collection.state.order ? 'descending' : 'ascending';
this.column.attributes.direction = directionWord;
}
Backgrid.HeaderCell.prototype.render.apply(this, arguments);
this.$el.html(this.template({
label: this.column.get('label')
}));
if (directionWord) { // this column is sorted
this.renderSortState(this.column, directionWord);
} else {
this.renderSortState();
}
return this;
LearnersBaseHeaderCell = BaseHeaderCell.extend({
tooltips: {
username: gettext('The name and username of this learner. Click to sort by username.'),
problems_attempted: gettext('Number of unique problems this learner attempted.'),
problems_completed: gettext('Number of unique problems the learner answered correctly.'),
videos_viewed: gettext('Number of unique videos this learner played.'),
// eslint-disable-next-line max-len
problem_attempts_per_completed: gettext('Average number of attempts per correct problem. Learners with a relatively high value compared to their peers may be struggling.'),
// eslint-disable-next-line max-len
discussion_contributions: gettext('Number of contributions by this learner, including posts, responses, and comments.')
},
onSort: function(column, direction) {
this.renderSortState(column, direction);
},
renderSortState: function(column, direction) {
var sortIcon = this.$('span.fa'),
sortDirectionMap,
directionOrNeutral;
if (column && column.cid !== this.column.cid) {
directionOrNeutral = 'neutral';
} else {
directionOrNeutral = direction || 'neutral';
}
// Maps a sort direction to its appropriate screen reader
// text and icon.
sortDirectionMap = {
// Translators: "sort ascending" describes the current
// sort state to the user.
ascending: {screenReaderText: gettext('sort ascending'), iconClass: 'fa fa-sort-asc'},
// Translators: "sort descending" describes the
// current sort state to the user.
descending: {screenReaderText: gettext('sort descending'), iconClass: 'fa fa-sort-desc'},
// Translators: "click to sort" tells the user that
// they can click this link to sort by the current
// field.
neutral: {screenReaderText: gettext('click to sort'), iconClass: 'fa fa-sort'}
};
sortIcon.removeClass('fa-sort fa-sort-asc fa-sort-desc');
sortIcon.addClass(sortDirectionMap[directionOrNeutral].iconClass);
this.$('.sr-sorting-text').text(' ' + sortDirectionMap[directionOrNeutral].screenReaderText);
}
container: '.learners-table'
});
return BaseHeaderCell;
return LearnersBaseHeaderCell;
});
......@@ -5,15 +5,15 @@ define(function(require) {
'use strict';
var _ = require('underscore'),
Marionette = require('marionette'),
ParentView = require('components/generic-list/common/views/parent-view'),
Filter = require('learners/roster/views/filter'),
LearnerFilter = require('learners/roster/views/filter'),
LearnerSearch = require('learners/roster/views/search'),
rosterControlsTemplate = require('text!learners/roster/templates/controls.underscore'),
RosterControlsView;
RosterControlsView = Marionette.LayoutView.extend({
RosterControlsView = ParentView.extend({
template: _.template(rosterControlsTemplate),
regions: {
......@@ -25,36 +25,57 @@ define(function(require) {
initialize: function(options) {
this.options = options || {};
},
onBeforeShow: function() {
this.showChildView('search', new LearnerSearch({
collection: this.options.collection,
name: 'text_search',
placeholder: gettext('Find a learner'),
trackingModel: this.options.trackingModel
}));
this.showChildView('cohortFilter', new Filter({
collection: this.options.collection,
filterKey: 'cohort',
filterValues: this.options.courseMetadata.get('cohorts'),
selectDisplayName: gettext('Cohort Groups'),
trackingModel: this.options.trackingModel
}));
this.showChildView('enrollmentTrackFilter', new Filter({
collection: this.options.collection,
filterKey: 'enrollment_mode',
filterValues: this.options.courseMetadata.get('enrollment_modes'),
selectDisplayName: gettext('Enrollment Tracks'),
trackingModel: this.options.trackingModel
}));
this.showChildView('activeFilter', new Filter({
collection: this.options.collection,
filterKey: 'ignore_segments',
filterValues: this.options.courseMetadata.get('segments'),
selectDisplayName: gettext('Inactive Learners'),
trackingModel: this.options.trackingModel
}));
this.childViews = [
{
region: 'search',
class: LearnerSearch,
options: {
collection: this.options.collection,
name: 'text_search',
placeholder: gettext('Find a learner'),
trackingModel: this.options.trackingModel
}
},
{
region: 'cohortFilter',
class: LearnerFilter,
options: {
collection: this.options.collection,
filterKey: 'cohort',
filterValues: this.options.courseMetadata.get('cohorts'),
filterInput: 'select',
selectDisplayName: gettext('Cohort Groups'),
trackingModel: this.options.trackingModel
}
},
{
region: 'enrollmentTrackFilter',
class: LearnerFilter,
options: {
collection: this.options.collection,
filterKey: 'enrollment_mode',
filterValues: this.options.courseMetadata.get('enrollment_modes'),
filterInput: 'select',
selectDisplayName: gettext('Enrollment Tracks'),
trackingModel: this.options.trackingModel
}
},
{
region: 'activeFilter',
class: LearnerFilter,
options: {
collection: this.options.collection,
filterKey: 'ignore_segments',
filterValues: this.options.courseMetadata.get('segments'),
filterInput: 'checkbox',
// Translators: inactive meaning that these learners have not interacted with the course
// recently.
selectDisplayName: gettext('Hide Inactive Learners'),
trackingModel: this.options.trackingModel
}
}
];
}
});
......
......@@ -88,7 +88,7 @@ define(function(require) {
.sortBy('name')
.value();
if (filterValues.length) {
if (filterValues.length && this.options.filterInput === 'select') {
filterValues.unshift({
name: this.catchAllFilterValue,
// Translators: "All" refers to viewing all the learners in a course.
......@@ -119,15 +119,23 @@ define(function(require) {
},
onCheckboxFilter: function(event) {
if ($(event.currentTarget).find('input:checkbox:checked').length) {
this.collection.setFilterField('ignore_segments', 'inactive');
var $inputs = $(event.currentTarget).find('input:checkbox:checked'),
filterKey = $(event.currentTarget).attr('id').slice(7), // chop off "filter-" prefix
appliedFilters = [],
filterValue = '';
if ($inputs.length) {
_.each($inputs, _.bind(function(input) {
appliedFilters.push($(input).attr('id'));
}, this));
filterValue = appliedFilters.join(',');
this.collection.setFilterField(filterKey, filterValue);
} else {
this.collection.unsetFilterField('ignore_segments');
this.collection.unsetFilterField(filterKey);
}
this.collection.refresh();
$('#learner-app-focusable').focus();
this.options.trackingModel.trigger('segment:track', 'edx.bi.roster.filtered', {
category: 'inactive'
category: filterValue
});
},
......
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