diff --git a/.travis.yml b/.travis.yml index 02e0afe62..409c1041a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/.travis/docker-compose-travis.yml b/.travis/docker-compose-travis.yml index 380b28e8d..af6825510 100644 --- a/.travis/docker-compose-travis.yml +++ b/.travis/docker-compose-travis.yml @@ -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" diff --git a/Makefile b/Makefile index 755e15f7d..ed443209d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f63992ac6..61694782a 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/acceptance_tests/mixins.py b/acceptance_tests/mixins.py index dba8ec350..358575f5c 100644 --- a/acceptance_tests/mixins.py +++ b/acceptance_tests/mixins.py @@ -86,7 +86,20 @@ def assertValidFeedbackLink(self, selector): 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 @@ def assertTable(self, table_selector, columns, download_selector): 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 @@ def _test_footer(self): 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 @@ def _test_active_course(self): 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 @@ def _test_skip_link(self): 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 @@ def _format_last_updated_time(self, d): 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 diff --git a/acceptance_tests/test_course_index.py b/acceptance_tests/test_course_index.py index 96cc09dd1..0abe8c6b9 100644 --- a/acceptance_tests/test_course_index.py +++ b/acceptance_tests/test_course_index.py @@ -9,6 +9,8 @@ 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 @@ def _test_course_list(self): """ 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))) diff --git a/acceptance_tests/test_course_learners.py b/acceptance_tests/test_course_learners.py index 0ff357347..4224aa366 100644 --- a/acceptance_tests/test_course_learners.py +++ b/acceptance_tests/test_course_learners.py @@ -9,6 +9,7 @@ @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): diff --git a/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.mo b/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.mo index a19763bcb..e2e73ba13 100644 Binary files a/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.mo and b/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.mo differ diff --git a/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.po b/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.po index 141dd606a..7d6e77e46 100644 --- a/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.po +++ b/analytics_dashboard/conf/locale/en/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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 "" diff --git a/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.mo b/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.mo index a19763bcb..e2e73ba13 100644 Binary files a/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.mo and b/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.po b/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.po index cb03702b9..c99bcaf6c 100644 --- a/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.po +++ b/analytics_dashboard/conf/locale/en/LC_MESSAGES/djangojs.po @@ -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 \n" "Language-Team: LANGUAGE \n" @@ -16,39 +16,258 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Language: \n" +#: static/apps/components/download/views/download-data.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Download CSV" +msgstr "" + +#: static/apps/components/download/views/download-data.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Download search results to CSV" +msgstr "" + +#: static/apps/components/generic-list/list/views/base-header-cell.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "sort ascending" +msgstr "" + +#: static/apps/components/generic-list/list/views/base-header-cell.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "sort descending" +msgstr "" + +#: static/apps/components/generic-list/list/views/base-header-cell.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "click to sort" +msgstr "" + +#: static/apps/components/generic-list/list/views/paging-footer.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "first page" +msgstr "" + +#: static/apps/components/generic-list/list/views/paging-footer.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "previous page" +msgstr "" + +#: static/apps/components/generic-list/list/views/paging-footer.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "next page" +msgstr "" + +#: static/apps/components/generic-list/list/views/paging-footer.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "last page" +msgstr "" + +#: static/apps/components/generic-list/list/views/paging-footer.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "page" +msgstr "" + +#: static/apps/components/generic-list/list/views/table.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Generic List" +msgstr "" + +#: static/apps/components/loading/views/loading-view.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Loading..." +msgstr "" + +#: static/apps/components/utils/utils.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "504: Server error" +msgstr "" + +#: static/apps/components/utils/utils.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "" +"Processing your request took too long to complete. Reload the page to try " +"again." +msgstr "" + +#: static/apps/components/utils/utils.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Server error" +msgstr "" + +#: static/apps/components/utils/utils.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Your request could not be processed. Reload the page to try again." +msgstr "" + +#: static/apps/components/utils/utils.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Network error" +msgstr "" + +#: static/apps/course-list/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Course List" +msgstr "" + +#: static/apps/course-list/app/controller.js #: static/apps/learners/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js #: static/dist/apps/learners/app/learners-main.js msgid "Invalid Parameters" msgstr "" +#: static/apps/course-list/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Sorry, we couldn't find any courses that matched that query." +msgstr "" + +#: static/apps/course-list/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Return to the Course List page." +msgstr "" + +#: static/apps/course-list/app/controller.js #: static/apps/learners/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js #: static/dist/apps/learners/app/learners-main.js -msgid "Sorry, we couldn't find any learners who matched that query." +msgid "Sorry, we couldn't find the page you're looking for." msgstr "" +#: static/apps/course-list/app/controller.js #: static/apps/learners/app/controller.js +#: static/dist/apps/course-list/app/course-list-main.js #: static/dist/apps/learners/app/learners-main.js -msgid "Return to the Learners page." +msgid "Page Not Found" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Course Name" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Start Date" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "End Date" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Total Enrollment" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/js/enrollment-activity-main.js +#: static/dist/js/enrollment-demographics-gender-main.js +#: static/dist/js/enrollment-geography-main.js +#: static/js/enrollment-activity-main.js +#: static/js/enrollment-demographics-gender-main.js +#: static/js/enrollment-geography-main.js +msgid "Current Enrollment" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Change Last Week" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Verified Enrollment" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Availability" +msgstr "" + +#: static/apps/course-list/common/collections/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Pacing Type" +msgstr "" + +#: static/apps/course-list/list/views/course-list.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Course list controls" +msgstr "" + +#: static/apps/course-list/list/views/results.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "No courses matched your criteria." +msgstr "" + +#: static/apps/course-list/list/views/results.js +#: static/apps/learners/roster/views/results.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Try a different search." +msgstr "" + +#: static/apps/course-list/list/views/results.js +#: static/apps/learners/roster/views/results.js +#: static/dist/apps/course-list/app/course-list-main.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Try clearing the filters." +msgstr "" + +#: static/apps/course-list/list/views/results.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "No course data is currently available for your course." +msgstr "" + +#: static/apps/course-list/list/views/results.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "" +"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." +msgstr "" + +#: static/apps/course-list/list/views/table.js +#: static/dist/apps/course-list/app/course-list-main.js +msgid "Date not available" msgstr "" #: static/apps/learners/app/controller.js #: static/dist/apps/learners/app/learners-main.js -msgid "Learners" +msgid "Learner Roster" msgstr "" #: static/apps/learners/app/controller.js #: static/dist/apps/learners/app/learners-main.js -msgid "Learner Details" +msgid "Sorry, we couldn't find any learners who matched that query." msgstr "" #: static/apps/learners/app/controller.js #: static/dist/apps/learners/app/learners-main.js -msgid "Sorry, we couldn't find the page you're looking for." +msgid "Return to the Learners page." msgstr "" #: static/apps/learners/app/controller.js #: static/dist/apps/learners/app/learners-main.js -msgid "Page Not Found" +msgid "Learners" +msgstr "" + +#: static/apps/learners/app/controller.js +#: static/dist/apps/learners/app/learners-main.js +msgid "Learner Details" msgstr "" #: static/apps/learners/common/collections/learners.js @@ -106,48 +325,6 @@ msgstr "" msgid "Enrollment Mode" msgstr "" -#: static/apps/learners/common/utils.js -#: static/dist/apps/learners/app/learners-main.js -msgid "504: Server error" -msgstr "" - -#: static/apps/learners/common/utils.js -#: static/dist/apps/learners/app/learners-main.js -msgid "" -"Processing your request took too long to complete. Reload the page to try " -"again." -msgstr "" - -#: static/apps/learners/common/utils.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Server error" -msgstr "" - -#: static/apps/learners/common/utils.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Your request could not be processed. Reload the page to try again." -msgstr "" - -#: static/apps/learners/common/utils.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Network error" -msgstr "" - -#: static/apps/learners/common/views/download-data.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Download CSV" -msgstr "" - -#: static/apps/learners/common/views/download-data.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Download search results to CSV" -msgstr "" - -#: static/apps/learners/common/views/loading-view.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Loading..." -msgstr "" - #: static/apps/learners/detail/views/engagement-table.js #: static/apps/learners/detail/views/engagement-timeline.js #: static/dist/apps/learners/app/learners-main.js @@ -303,21 +480,6 @@ msgid "" "comments." msgstr "" -#: static/apps/learners/roster/views/base-header-cell.js -#: static/dist/apps/learners/app/learners-main.js -msgid "sort ascending" -msgstr "" - -#: static/apps/learners/roster/views/base-header-cell.js -#: static/dist/apps/learners/app/learners-main.js -msgid "sort descending" -msgstr "" - -#: static/apps/learners/roster/views/base-header-cell.js -#: static/dist/apps/learners/app/learners-main.js -msgid "click to sort" -msgstr "" - #: static/apps/learners/roster/views/controls.js #: static/dist/apps/learners/app/learners-main.js msgid "Find a learner" @@ -334,8 +496,9 @@ msgid "Enrollment Tracks" msgstr "" #: static/apps/learners/roster/views/controls.js +#: static/apps/learners/roster/views/filter.js #: static/dist/apps/learners/app/learners-main.js -msgid "Inactive Learners" +msgid "Hide Inactive Learners" msgstr "" #: static/apps/learners/roster/views/filter.js @@ -348,51 +511,11 @@ msgstr "" msgid "All" msgstr "" -#: static/apps/learners/roster/views/filter.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Hide Inactive Learners" -msgstr "" - -#: static/apps/learners/roster/views/paging-footer.js -#: static/dist/apps/learners/app/learners-main.js -msgid "first page" -msgstr "" - -#: static/apps/learners/roster/views/paging-footer.js -#: static/dist/apps/learners/app/learners-main.js -msgid "previous page" -msgstr "" - -#: static/apps/learners/roster/views/paging-footer.js -#: static/dist/apps/learners/app/learners-main.js -msgid "next page" -msgstr "" - -#: static/apps/learners/roster/views/paging-footer.js -#: static/dist/apps/learners/app/learners-main.js -msgid "last page" -msgstr "" - -#: static/apps/learners/roster/views/paging-footer.js -#: static/dist/apps/learners/app/learners-main.js -msgid "page" -msgstr "" - #: static/apps/learners/roster/views/results.js #: static/dist/apps/learners/app/learners-main.js msgid "No learners matched your criteria." msgstr "" -#: static/apps/learners/roster/views/results.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Try a different search." -msgstr "" - -#: static/apps/learners/roster/views/results.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Try clearing the filters." -msgstr "" - #: static/apps/learners/roster/views/results.js #: static/dist/apps/learners/app/learners-main.js msgid "No learner data is currently available for your course." @@ -431,11 +554,6 @@ msgstr "" msgid "N/A" msgstr "" -#: static/apps/learners/roster/views/table.js -#: static/dist/apps/learners/app/learners-main.js -msgid "Learner Roster" -msgstr "" - #: static/dist/js/common.js static/js/utils/utils.js msgid "..." msgstr "" @@ -562,15 +680,6 @@ msgstr "" msgid "Incomplete Views" msgstr "" -#: static/dist/js/enrollment-activity-main.js -#: static/dist/js/enrollment-demographics-gender-main.js -#: static/dist/js/enrollment-geography-main.js -#: static/js/enrollment-activity-main.js -#: static/js/enrollment-demographics-gender-main.js -#: static/js/enrollment-geography-main.js -msgid "Current Enrollment" -msgstr "" - #: static/dist/js/enrollment-activity-main.js #: static/js/enrollment-activity-main.js msgid "Honor" diff --git a/analytics_dashboard/courses/presenters/__init__.py b/analytics_dashboard/courses/presenters/__init__.py index 873af1afe..89f2bc1a5 100644 --- a/analytics_dashboard/courses/presenters/__init__.py +++ b/analytics_dashboard/courses/presenters/__init__.py @@ -16,17 +16,11 @@ 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 @@ def sum_counts(data): 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 diff --git a/analytics_dashboard/courses/presenters/course_summaries.py b/analytics_dashboard/courses/presenters/course_summaries.py new file mode 100644 index 000000000..5eb575e49 --- /dev/null +++ b/analytics_dashboard/courses/presenters/course_summaries.py @@ -0,0 +1,53 @@ +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) diff --git a/analytics_dashboard/courses/presenters/engagement.py b/analytics_dashboard/courses/presenters/engagement.py index 5cb4e1e14..7f18a9374 100644 --- a/analytics_dashboard/courses/presenters/engagement.py +++ b/analytics_dashboard/courses/presenters/engagement.py @@ -13,13 +13,13 @@ 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 @@ def get_summary_and_trend_data(self): return summary, trends -class CourseEngagementVideoPresenter(CourseAPIPresenterMixin, BasePresenter): +class CourseEngagementVideoPresenter(CourseAPIPresenterMixin, CoursePresenter): def blocks_have_data(self, videos): if videos: diff --git a/analytics_dashboard/courses/presenters/enrollment.py b/analytics_dashboard/courses/presenters/enrollment.py index 236ae3345..62c637961 100644 --- a/analytics_dashboard/courses/presenters/enrollment.py +++ b/analytics_dashboard/courses/presenters/enrollment.py @@ -9,7 +9,7 @@ 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 @@ } -class CourseEnrollmentPresenter(BasePresenter): +class CourseEnrollmentPresenter(CoursePresenter): """ Presenter for the course enrollment data. """ NUMBER_TOP_COUNTRIES = 3 @@ -276,7 +276,7 @@ def _build_summary(self, api_trends): return data -class CourseEnrollmentDemographicsPresenter(BasePresenter): +class CourseEnrollmentDemographicsPresenter(CoursePresenter): """ Presenter for course enrollment demographic data. """ # ages at this and above will be binned diff --git a/analytics_dashboard/courses/presenters/performance.py b/analytics_dashboard/courses/presenters/performance.py index 0296191c5..eaa81466e 100644 --- a/analytics_dashboard/courses/presenters/performance.py +++ b/analytics_dashboard/courses/presenters/performance.py @@ -13,7 +13,7 @@ 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 @@ ]) -class CoursePerformancePresenter(CourseAPIPresenterMixin, BasePresenter): +class CoursePerformancePresenter(CourseAPIPresenterMixin, CoursePresenter): """ Presenter for the performance page. """ @@ -395,7 +395,7 @@ def module_graded_type(self): return False -class TagsDistributionPresenter(CourseAPIPresenterMixin, BasePresenter): +class TagsDistributionPresenter(CourseAPIPresenterMixin, CoursePresenter): """ Presenter for the tags distribution page. """ @@ -606,7 +606,7 @@ def get_modules_marked_with_tag(self, tag_key, tag_value): return result -class CourseReportDownloadPresenter(BasePresenter): +class CourseReportDownloadPresenter(CoursePresenter): """ Presenter that can fetch temporary CSV download URLs from the data API """ diff --git a/analytics_dashboard/courses/templates/courses/_data_last_updated.html b/analytics_dashboard/courses/templates/courses/_data_last_updated.html new file mode 100644 index 000000000..5d85bfdc6 --- /dev/null +++ b/analytics_dashboard/courses/templates/courses/_data_last_updated.html @@ -0,0 +1,6 @@ +{% if update_message %} +
{{ update_message }}
+{% endif %} +{% if data_information_message %} +
{{ data_information_message }}
+{% endif %} diff --git a/analytics_dashboard/courses/templates/courses/base-course.html b/analytics_dashboard/courses/templates/courses/base-course.html index 2d77159fc..6bc7e98d4 100644 --- a/analytics_dashboard/courses/templates/courses/base-course.html +++ b/analytics_dashboard/courses/templates/courses/base-course.html @@ -10,11 +10,6 @@ {% block child_content %} {% endblock %} {% block data_messaging %} - {% if update_message %} -
{{ update_message }}
- {% endif %} - {% if data_information_message %} -
{{ data_information_message }}
- {% endif %} + {% include "courses/_data_last_updated.html" with update_message=update_message data_information_message=data_information_message %} {% endblock %} {% endblock %} diff --git a/analytics_dashboard/courses/templates/courses/index.html b/analytics_dashboard/courses/templates/courses/index.html index 255c481e2..3414da1b8 100644 --- a/analytics_dashboard/courses/templates/courses/index.html +++ b/analytics_dashboard/courses/templates/courses/index.html @@ -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 %} -

+

{% blocktrans with username=request.user.username %}Welcome, {{ username }}!{% endblocktrans %}

{% endblock %} @@ -18,35 +19,25 @@

{% blocktrans %}Here are the courses you currently have access to in {{ application_name }}:{% endblocktrans %} {% endblock intro-text %} -{% block content %} -
-
-
- {% for course in courses %} -
- - {% firstof course.name course.key|format_course_key %} - - - {% if course.name %} -
{{ course.key|format_course_key:" / " }}
- {% endif %} -
- {% endfor %} -
-
+{% block stylesheets %} + {{ block.super }} + +{% endblock stylesheets %} -
-
-

{% blocktrans %}New to {{ application_name }}?{% endblocktrans %}

+{% block javascript %} + {{ block.super }} + +{% endblock javascript %} - {% captureas email_link %} - {% endcaptureas %} - -

{% blocktrans trimmed %} Click Help in the upper-right corner to get more information - about {{ application_name }}. Send us feedback at {{ email_link }}.{% endblocktrans %}

+{% block content %} + {% block child_content %} +
+
+ {% include "loading.html" %}
-
-
+ + {% endblock %} + {% block data_messaging %} + {% include "courses/_data_last_updated.html" with update_message=update_message data_information_message=data_information_message %} + {% endblock %} {% endblock %} diff --git a/analytics_dashboard/courses/tests/test_presenters/__init__.py b/analytics_dashboard/courses/tests/test_presenters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics_dashboard/courses/tests/test_presenters/test_course_summaries.py b/analytics_dashboard/courses/tests/test_presenters/test_course_summaries.py new file mode 100644 index 000000000..25e38ab78 --- /dev/null +++ b/analytics_dashboard/courses/tests/test_presenters/test_course_summaries.py @@ -0,0 +1,141 @@ +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) diff --git a/analytics_dashboard/courses/tests/test_presenters.py b/analytics_dashboard/courses/tests/test_presenters/test_presenters.py similarity index 99% rename from analytics_dashboard/courses/tests/test_presenters.py rename to analytics_dashboard/courses/tests/test_presenters/test_presenters.py index 48e706f12..0a46ff19e 100644 --- a/analytics_dashboard/courses/tests/test_presenters.py +++ b/analytics_dashboard/courses/tests/test_presenters/test_presenters.py @@ -24,7 +24,7 @@ ) 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 @@ 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): diff --git a/analytics_dashboard/courses/tests/test_views/__init__.py b/analytics_dashboard/courses/tests/test_views/__init__.py index 4557d2b53..7fe5a923d 100644 --- a/analytics_dashboard/courses/tests/test_views/__init__.py +++ b/analytics_dashboard/courses/tests/test_views/__init__.py @@ -15,12 +15,14 @@ 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 @@ def mock_course_detail(self, course_id, extra=None): 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 @@ def test_authentication(self, course_id): 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 @@ def assertViewIsValid(self, course_id): 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 @@ def test_valid_course(self, course_id): 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 @@ def test_valid_course(self): 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 @@ def _test_api_error(self): # 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) diff --git a/analytics_dashboard/courses/tests/test_views/test_course_summaries.py b/analytics_dashboard/courses/tests/test_views/test_course_summaries.py new file mode 100644 index 000000000..595e41017 --- /dev/null +++ b/analytics_dashboard/courses/tests/test_views/test_course_summaries.py @@ -0,0 +1,62 @@ +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) diff --git a/analytics_dashboard/courses/tests/test_views/test_csv.py b/analytics_dashboard/courses/tests/test_views/test_csv.py index 477fd10fe..ca6c2fc11 100644 --- a/analytics_dashboard/courses/tests/test_views/test_csv.py +++ b/analytics_dashboard/courses/tests/test_views/test_csv.py @@ -4,10 +4,17 @@ 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 @@ def assertIsValidCSV(self, course_id, csv_data): 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 @@ def _test_csv(self, course_id, csv_data): 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) diff --git a/analytics_dashboard/courses/tests/test_views/test_engagement.py b/analytics_dashboard/courses/tests/test_views/test_engagement.py index 55098486e..4ed7f9a93 100644 --- a/analytics_dashboard/courses/tests/test_views/test_engagement.py +++ b/analytics_dashboard/courses/tests/test_views/test_engagement.py @@ -13,12 +13,12 @@ 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 @@ def assertValidContext(self, context): @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 @@ def assertValidContext(self, context): @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 @@ def assertValidContext(self, context): @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 @@ def assertValidContext(self, context): @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) diff --git a/analytics_dashboard/courses/tests/test_views/test_learners.py b/analytics_dashboard/courses/tests/test_views/test_learners.py index c42b5860e..4c91054be 100644 --- a/analytics_dashboard/courses/tests/test_views/test_learners.py +++ b/analytics_dashboard/courses/tests/test_views/test_learners.py @@ -13,7 +13,8 @@ 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 @@ def _register_uris(self, learners_status, learners_payload, course_metadata_stat 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 @@ def _register_uris(self, learners_status, learners_payload, course_metadata_stat 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) diff --git a/analytics_dashboard/courses/tests/test_views/test_mixins.py b/analytics_dashboard/courses/tests/test_views/test_mixins.py index 9e054197d..bb5d2d5e1 100644 --- a/analytics_dashboard/courses/tests/test_views/test_mixins.py +++ b/analytics_dashboard/courses/tests/test_views/test_mixins.py @@ -2,14 +2,14 @@ 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): diff --git a/analytics_dashboard/courses/tests/test_views/test_pages.py b/analytics_dashboard/courses/tests/test_views/test_pages.py index 123b52811..ff13fc773 100644 --- a/analytics_dashboard/courses/tests/test_views/test_pages.py +++ b/analytics_dashboard/courses/tests/test_views/test_pages.py @@ -9,11 +9,8 @@ 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 @@ def assert_performance_report_link_present(self, expected): """ 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 @@ def assert_performance_report_link_present(self, expected): 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 @@ def test_course_overview(self): 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 @@ def test_performance_problem_response_link_enabled(self, available, mock_get_rep 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) diff --git a/analytics_dashboard/courses/tests/test_views/test_performance.py b/analytics_dashboard/courses/tests/test_views/test_performance.py index d2c305660..1292fb2a0 100644 --- a/analytics_dashboard/courses/tests/test_views/test_performance.py +++ b/analytics_dashboard/courses/tests/test_views/test_performance.py @@ -17,7 +17,8 @@ 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 @@ def test_course_api_error(self): """ # 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 @@ def test_valid_course(self): @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 @@ def test_missing_distribution_data(self): """ 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 @@ def test_missing_assignments(self): 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 @@ def test_missing_assignment(self): 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 @@ def assertValidContext(self, context): @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 @@ def assertValidContext(self, context): @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 @@ def test_valid_course(self): 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) diff --git a/analytics_dashboard/courses/tests/utils.py b/analytics_dashboard/courses/tests/utils.py index 93317abcd..6c86ccef2 100644 --- a/analytics_dashboard/courses/tests/utils.py +++ b/analytics_dashboard/courses/tests/utils.py @@ -20,6 +20,12 @@ 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) diff --git a/analytics_dashboard/courses/urls.py b/analytics_dashboard/courses/urls.py index 1c68b1249..d93779b2c 100644 --- a/analytics_dashboard/courses/urls.py +++ b/analytics_dashboard/courses/urls.py @@ -3,7 +3,14 @@ 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(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' PROBLEM_PART_ID_PATTERN = CONTENT_ID_PATTERN.replace('content_id', 'problem_part_id') @@ -127,6 +134,6 @@ 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)) ] diff --git a/analytics_dashboard/courses/views/__init__.py b/analytics_dashboard/courses/views/__init__.py index 4a4bcd6eb..780100374 100644 --- a/analytics_dashboard/courses/views/__init__.py +++ b/analytics_dashboard/courses/views/__init__.py @@ -5,7 +5,6 @@ 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 @@ def get_context_data(self, **kwargs): 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 @@ def format_last_updated_date_and_time(d): '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 @@ def format_date(date): 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 diff --git a/analytics_dashboard/courses/views/course_summaries.py b/analytics_dashboard/courses/views/course_summaries.py new file mode 100644 index 000000000..edd22f7b9 --- /dev/null +++ b/analytics_dashboard/courses/views/course_summaries.py @@ -0,0 +1,55 @@ +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 diff --git a/analytics_dashboard/settings/production.py b/analytics_dashboard/settings/production.py index 4340b9c24..b8f0315ac 100644 --- a/analytics_dashboard/settings/production.py +++ b/analytics_dashboard/settings/production.py @@ -4,7 +4,6 @@ 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 diff --git a/analytics_dashboard/static/apps/learners/common/views/spec/alert-view-spec.js b/analytics_dashboard/static/apps/components/alert/spec/alert-view-spec.js similarity index 97% rename from analytics_dashboard/static/apps/learners/common/views/spec/alert-view-spec.js rename to analytics_dashboard/static/apps/components/alert/spec/alert-view-spec.js index 9bfd774e1..da9ff4941 100644 --- a/analytics_dashboard/static/apps/learners/common/views/spec/alert-view-spec.js +++ b/analytics_dashboard/static/apps/components/alert/spec/alert-view-spec.js @@ -1,7 +1,7 @@ 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() { diff --git a/analytics_dashboard/static/apps/learners/common/templates/alert.underscore b/analytics_dashboard/static/apps/components/alert/templates/alert.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/common/templates/alert.underscore rename to analytics_dashboard/static/apps/components/alert/templates/alert.underscore diff --git a/analytics_dashboard/static/apps/learners/common/views/alert-view.js b/analytics_dashboard/static/apps/components/alert/views/alert-view.js similarity index 96% rename from analytics_dashboard/static/apps/learners/common/views/alert-view.js rename to analytics_dashboard/static/apps/components/alert/views/alert-view.js index 65865d506..da49b88db 100644 --- a/analytics_dashboard/static/apps/learners/common/views/alert-view.js +++ b/analytics_dashboard/static/apps/components/alert/views/alert-view.js @@ -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; diff --git a/analytics_dashboard/static/apps/learners/common/views/spec/download-data-spec.js b/analytics_dashboard/static/apps/components/download/spec/download-data-spec.js similarity index 90% rename from analytics_dashboard/static/apps/learners/common/views/spec/download-data-spec.js rename to analytics_dashboard/static/apps/components/download/spec/download-data-spec.js index a3790d124..9656b214a 100644 --- a/analytics_dashboard/static/apps/learners/common/views/spec/download-data-spec.js +++ b/analytics_dashboard/static/apps/components/download/spec/download-data-spec.js @@ -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({ diff --git a/analytics_dashboard/static/apps/learners/common/templates/download-data.underscore b/analytics_dashboard/static/apps/components/download/templates/download-data.underscore similarity index 82% rename from analytics_dashboard/static/apps/learners/common/templates/download-data.underscore rename to analytics_dashboard/static/apps/components/download/templates/download-data.underscore index b49d1bbc0..186090597 100644 --- a/analytics_dashboard/static/apps/learners/common/templates/download-data.underscore +++ b/analytics_dashboard/static/apps/components/download/templates/download-data.underscore @@ -1,7 +1,7 @@ <% if (hasDownloadData) { %>
<%- downloadDataTitle %> <%- downloadDataMessage %> diff --git a/analytics_dashboard/static/apps/learners/common/views/download-data.js b/analytics_dashboard/static/apps/components/download/views/download-data.js similarity index 97% rename from analytics_dashboard/static/apps/learners/common/views/download-data.js rename to analytics_dashboard/static/apps/components/download/views/download-data.js index 288f2c7a7..c6ad5aace 100644 --- a/analytics_dashboard/static/apps/learners/common/views/download-data.js +++ b/analytics_dashboard/static/apps/components/download/views/download-data.js @@ -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({ diff --git a/analytics_dashboard/static/apps/components/generic-list/common/collections/collection.js b/analytics_dashboard/static/apps/components/generic-list/common/collections/collection.js new file mode 100644 index 000000000..579795c96 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/common/collections/collection.js @@ -0,0 +1,116 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/components/generic-list/common/collections/spec/collection-spec.js b/analytics_dashboard/static/apps/components/generic-list/common/collections/spec/collection-spec.js new file mode 100644 index 000000000..1bbf8f521 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/common/collections/spec/collection-spec.js @@ -0,0 +1,132 @@ +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'); + }); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/learners/common/models/page.js b/analytics_dashboard/static/apps/components/generic-list/common/models/page.js similarity index 100% rename from analytics_dashboard/static/apps/learners/common/models/page.js rename to analytics_dashboard/static/apps/components/generic-list/common/models/page.js diff --git a/analytics_dashboard/static/apps/learners/common/models/spec/page-spec.js b/analytics_dashboard/static/apps/components/generic-list/common/models/spec/page-spec.js similarity index 85% rename from analytics_dashboard/static/apps/learners/common/models/spec/page-spec.js rename to analytics_dashboard/static/apps/components/generic-list/common/models/spec/page-spec.js index 72ad9147c..3598fcc38 100644 --- a/analytics_dashboard/static/apps/learners/common/models/spec/page-spec.js +++ b/analytics_dashboard/static/apps/components/generic-list/common/models/spec/page-spec.js @@ -1,7 +1,7 @@ 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() { diff --git a/analytics_dashboard/static/apps/components/generic-list/common/views/parent-view.js b/analytics_dashboard/static/apps/components/generic-list/common/views/parent-view.js new file mode 100644 index 000000000..433b272d6 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/common/views/parent-view.js @@ -0,0 +1,42 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/learners/roster/templates/base-header-cell.underscore b/analytics_dashboard/static/apps/components/generic-list/list/templates/base-header-cell.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/roster/templates/base-header-cell.underscore rename to analytics_dashboard/static/apps/components/generic-list/list/templates/base-header-cell.underscore diff --git a/analytics_dashboard/static/apps/learners/roster/templates/page-handle.underscore b/analytics_dashboard/static/apps/components/generic-list/list/templates/page-handle.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/roster/templates/page-handle.underscore rename to analytics_dashboard/static/apps/components/generic-list/list/templates/page-handle.underscore diff --git a/analytics_dashboard/static/apps/components/generic-list/list/templates/table.underscore b/analytics_dashboard/static/apps/components/generic-list/list/templates/table.underscore new file mode 100644 index 000000000..0d8a13a8d --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/list/templates/table.underscore @@ -0,0 +1,4 @@ +
+
+ +
diff --git a/analytics_dashboard/static/apps/components/generic-list/list/views/base-header-cell.js b/analytics_dashboard/static/apps/components/generic-list/list/views/base-header-cell.js new file mode 100644 index 000000000..7f61baa29 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/list/views/base-header-cell.js @@ -0,0 +1,85 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/components/generic-list/list/views/list.js b/analytics_dashboard/static/apps/components/generic-list/list/views/list.js new file mode 100644 index 000000000..805fa2aa6 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/list/views/list.js @@ -0,0 +1,47 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/learners/roster/views/paging-footer.js b/analytics_dashboard/static/apps/components/generic-list/list/views/paging-footer.js similarity index 62% rename from analytics_dashboard/static/apps/learners/roster/views/paging-footer.js rename to analytics_dashboard/static/apps/components/generic-list/list/views/paging-footer.js index 2742de1e5..133f5b930 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/paging-footer.js +++ b/analytics_dashboard/static/apps/components/generic-list/list/views/paging-footer.js @@ -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 }); } diff --git a/analytics_dashboard/static/apps/components/generic-list/list/views/table.js b/analytics_dashboard/static/apps/components/generic-list/list/views/table.js new file mode 100644 index 000000000..70c9e1c98 --- /dev/null +++ b/analytics_dashboard/static/apps/components/generic-list/list/views/table.js @@ -0,0 +1,100 @@ +/** + * 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('' + this.options.tableName + ''); + }, + + /** + * 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; +}); diff --git a/analytics_dashboard/static/apps/learners/app/views/spec/header-spec.js b/analytics_dashboard/static/apps/components/header/spec/header-spec.js similarity index 94% rename from analytics_dashboard/static/apps/learners/app/views/spec/header-spec.js rename to analytics_dashboard/static/apps/components/header/spec/header-spec.js index 399637647..69f60aafa 100644 --- a/analytics_dashboard/static/apps/learners/app/views/spec/header-spec.js +++ b/analytics_dashboard/static/apps/components/header/spec/header-spec.js @@ -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; diff --git a/analytics_dashboard/static/apps/learners/app/templates/header.underscore b/analytics_dashboard/static/apps/components/header/templates/header.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/app/templates/header.underscore rename to analytics_dashboard/static/apps/components/header/templates/header.underscore diff --git a/analytics_dashboard/static/apps/learners/app/views/header.js b/analytics_dashboard/static/apps/components/header/views/header.js similarity index 86% rename from analytics_dashboard/static/apps/learners/app/views/header.js rename to analytics_dashboard/static/apps/components/header/views/header.js index 5bed39ec5..ddc8ee415 100644 --- a/analytics_dashboard/static/apps/learners/app/views/header.js +++ b/analytics_dashboard/static/apps/components/header/views/header.js @@ -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; diff --git a/analytics_dashboard/static/apps/learners/common/views/spec/loading-view-spec.js b/analytics_dashboard/static/apps/components/loading/spec/loading-view-spec.js similarity index 92% rename from analytics_dashboard/static/apps/learners/common/views/spec/loading-view-spec.js rename to analytics_dashboard/static/apps/components/loading/spec/loading-view-spec.js index bcf0fdce0..857885910 100644 --- a/analytics_dashboard/static/apps/learners/common/views/spec/loading-view-spec.js +++ b/analytics_dashboard/static/apps/components/loading/spec/loading-view-spec.js @@ -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'; diff --git a/analytics_dashboard/static/apps/learners/detail/templates/chart-loading.underscore b/analytics_dashboard/static/apps/components/loading/templates/chart-loading.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/detail/templates/chart-loading.underscore rename to analytics_dashboard/static/apps/components/loading/templates/chart-loading.underscore diff --git a/analytics_dashboard/static/apps/learners/roster/templates/roster-loading.underscore b/analytics_dashboard/static/apps/components/loading/templates/plain-loading.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/roster/templates/roster-loading.underscore rename to analytics_dashboard/static/apps/components/loading/templates/plain-loading.underscore diff --git a/analytics_dashboard/static/apps/learners/detail/templates/table-loading.underscore b/analytics_dashboard/static/apps/components/loading/templates/table-loading.underscore similarity index 100% rename from analytics_dashboard/static/apps/learners/detail/templates/table-loading.underscore rename to analytics_dashboard/static/apps/components/loading/templates/table-loading.underscore diff --git a/analytics_dashboard/static/apps/learners/common/views/loading-view.js b/analytics_dashboard/static/apps/components/loading/views/loading-view.js similarity index 100% rename from analytics_dashboard/static/apps/learners/common/views/loading-view.js rename to analytics_dashboard/static/apps/components/loading/views/loading-view.js diff --git a/analytics_dashboard/static/apps/learners/app/views/spec/root-spec.js b/analytics_dashboard/static/apps/components/root/spec/root-spec.js similarity index 61% rename from analytics_dashboard/static/apps/learners/app/views/spec/root-spec.js rename to analytics_dashboard/static/apps/components/root/spec/root-spec.js index 79a9ef572..937e9a21f 100644 --- a/analytics_dashboard/static/apps/learners/app/views/spec/root-spec.js +++ b/analytics_dashboard/static/apps/components/root/spec/root-spec.js @@ -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('
'); - 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'); }); }); }); diff --git a/analytics_dashboard/static/apps/components/root/templates/root.underscore b/analytics_dashboard/static/apps/components/root/templates/root.underscore new file mode 100644 index 000000000..58126a62c --- /dev/null +++ b/analytics_dashboard/static/apps/components/root/templates/root.underscore @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/analytics_dashboard/static/apps/learners/app/views/root.js b/analytics_dashboard/static/apps/components/root/views/root.js similarity index 61% rename from analytics_dashboard/static/apps/learners/app/views/root.js rename to analytics_dashboard/static/apps/components/root/views/root.js index b8478aed9..58fe78e66 100644 --- a/analytics_dashboard/static/apps/learners/app/views/root.js +++ b/analytics_dashboard/static/apps/components/root/views/root.js @@ -1,5 +1,9 @@ /** * 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; }); diff --git a/analytics_dashboard/static/apps/components/skip-link/spec/skip-link-view-spec.js b/analytics_dashboard/static/apps/components/skip-link/spec/skip-link-view-spec.js new file mode 100644 index 000000000..e2a60f70b --- /dev/null +++ b/analytics_dashboard/static/apps/components/skip-link/spec/skip-link-view-spec.js @@ -0,0 +1,29 @@ +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 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(); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/components/skip-link/views/skip-link-view.js b/analytics_dashboard/static/apps/components/skip-link/views/skip-link-view.js new file mode 100644 index 000000000..12053ce59 --- /dev/null +++ b/analytics_dashboard/static/apps/components/skip-link/views/skip-link-view.js @@ -0,0 +1,40 @@ +/** + * 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(); + } + + }); +}); diff --git a/analytics_dashboard/static/apps/learners/common/spec/utils-spec.js b/analytics_dashboard/static/apps/components/utils/spec/utils-spec.js similarity index 87% rename from analytics_dashboard/static/apps/learners/common/spec/utils-spec.js rename to analytics_dashboard/static/apps/components/utils/spec/utils-spec.js index 326b3cb7f..eb027f5d3 100644 --- a/analytics_dashboard/static/apps/learners/common/spec/utils-spec.js +++ b/analytics_dashboard/static/apps/components/utils/spec/utils-spec.js @@ -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(); diff --git a/analytics_dashboard/static/apps/learners/common/utils.js b/analytics_dashboard/static/apps/components/utils/utils.js similarity index 100% rename from analytics_dashboard/static/apps/learners/common/utils.js rename to analytics_dashboard/static/apps/components/utils/utils.js diff --git a/analytics_dashboard/static/apps/course-list/app/app.js b/analytics_dashboard/static/apps/course-list/app/app.js new file mode 100644 index 000000000..598112a93 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/app.js @@ -0,0 +1,64 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/course-list/app/controller.js b/analytics_dashboard/static/apps/course-list/app/controller.js new file mode 100644 index 000000000..9b178e13b --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/controller.js @@ -0,0 +1,138 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/course-list/app/course-list-main.js b/analytics_dashboard/static/apps/course-list/app/course-list-main.js new file mode 100644 index 000000000..9fc5b31ab --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/course-list-main.js @@ -0,0 +1,12 @@ +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(); +}); diff --git a/analytics_dashboard/static/apps/course-list/app/router.js b/analytics_dashboard/static/apps/course-list/app/router.js new file mode 100644 index 000000000..20f598800 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/router.js @@ -0,0 +1,42 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/course-list/app/spec/controller-spec.js b/analytics_dashboard/static/apps/course-list/app/spec/controller-spec.js new file mode 100644 index 000000000..f1318ec76 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/spec/controller-spec.js @@ -0,0 +1,113 @@ +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('
'); + 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); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/course-list/app/spec/router-spec.js b/analytics_dashboard/static/apps/course-list/app/spec/router-spec.js new file mode 100644 index 000000000..d5c476d7e --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/app/spec/router-spec.js @@ -0,0 +1,90 @@ +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'); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/course-list/common/collections/course-list.js b/analytics_dashboard/static/apps/course-list/common/collections/course-list.js new file mode 100644 index 000000000..5133a10a7 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/common/collections/course-list.js @@ -0,0 +1,35 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/course-list/common/collections/spec/course-list-spec.js b/analytics_dashboard/static/apps/course-list/common/collections/spec/course-list-spec.js new file mode 100644 index 000000000..e1e019342 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/common/collections/spec/course-list-spec.js @@ -0,0 +1,76 @@ +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); + }); + }); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/course-list/common/models/course.js b/analytics_dashboard/static/apps/course-list/common/models/course.js new file mode 100644 index 000000000..26188ae4a --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/common/models/course.js @@ -0,0 +1,74 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/course-list/common/models/spec/course-spec.js b/analytics_dashboard/static/apps/course-list/common/models/spec/course-spec.js new file mode 100644 index 000000000..f01fff94d --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/common/models/spec/course-spec.js @@ -0,0 +1,71 @@ +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); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/course-list/list/templates/course-id-and-name-cell.underscore b/analytics_dashboard/static/apps/course-list/list/templates/course-id-and-name-cell.underscore new file mode 100644 index 000000000..305b4138b --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/templates/course-id-and-name-cell.underscore @@ -0,0 +1,8 @@ + +
+ <%- catalog_course_title %> +
+
+ <%- course_id %> +
+
diff --git a/analytics_dashboard/static/apps/course-list/list/templates/list.underscore b/analytics_dashboard/static/apps/course-list/list/templates/list.underscore new file mode 100644 index 000000000..4f5fda4f5 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/templates/list.underscore @@ -0,0 +1,15 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/analytics_dashboard/static/apps/course-list/list/templates/table.underscore b/analytics_dashboard/static/apps/course-list/list/templates/table.underscore new file mode 100644 index 000000000..e27fb2cc2 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/templates/table.underscore @@ -0,0 +1,4 @@ +
+
+ +
diff --git a/analytics_dashboard/static/apps/course-list/list/views/base-header-cell.js b/analytics_dashboard/static/apps/course-list/list/views/base-header-cell.js new file mode 100644 index 000000000..6e5318a37 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/base-header-cell.js @@ -0,0 +1,13 @@ +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; +}); diff --git a/analytics_dashboard/static/apps/course-list/list/views/course-id-and-name-cell.js b/analytics_dashboard/static/apps/course-list/list/views/course-id-and-name-cell.js new file mode 100644 index 000000000..7a1333350 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/course-id-and-name-cell.js @@ -0,0 +1,25 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/course-list/list/views/course-list.js b/analytics_dashboard/static/apps/course-list/list/views/course-list.js new file mode 100644 index 000000000..89efdefd1 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/course-list.js @@ -0,0 +1,51 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/course-list/list/views/results.js b/analytics_dashboard/static/apps/course-list/list/views/results.js new file mode 100644 index 000000000..43c28fb6c --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/results.js @@ -0,0 +1,75 @@ +/** + * 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('
'), + 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; +}); diff --git a/analytics_dashboard/static/apps/course-list/list/views/spec/course-list-spec.js b/analytics_dashboard/static/apps/course-list/list/views/spec/course-list-spec.js new file mode 100644 index 000000000..51ffa0937 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/spec/course-list-spec.js @@ -0,0 +1,302 @@ +define(function(require) { + 'use strict'; + + var $ = require('jquery'), + _ = require('underscore'), + axe = require('axe-core'), + moment = require('moment'), + SpecHelpers = require('uitk/utils/spec-helpers/spec-helpers'), + + Utils = require('utils/utils'), + + CourseList = require('course-list/common/collections/course-list'), + CourseListView = require('course-list/list/views/course-list'), + CourseModel = require('course-list/common/models/course'), + TrackingModel = require('models/tracking-model'); + + + describe('LearnerRosterView', function() { + var fixtureClass = 'course-list-view-fixture', + clickPagingControl, + getCourseListView; + + getCourseListView = function(options, pageSize) { + var collection, + view, + defaultOptions = _.defaults({}, options); + collection = defaultOptions.collection || new CourseList([ + // default course data + new CourseModel({ + catalog_course_title: 'Alpaca', + course_id: 'this/course/id', + count: 10, + cumulative_count: 20, + count_change_7_days: 30, + verified_enrollment: 40, + start_date: '2015-02-17T050000', + end_date: '2015-03-31T000000' + }), + new CourseModel({ + catalog_course_title: 'zebra', + course_id: 'another-course-id', + count: 0, + cumulative_count: 1000, + count_change_7_days: -10, + verified_enrollment: 1000, + start_date: '2016-11-17T050000', + end_date: '2016-12-01T000000' + })], + _.extend({mode: 'client'}, defaultOptions.collectionOptions) + ); + + if (pageSize) { + collection.setPageSize(pageSize); + } + view = new CourseListView({ + collection: collection, + el: '.' + fixtureClass, + trackSubject: 'course_list', + hasData: true, + trackingModel: new TrackingModel(), + tableName: 'Course List', + appClass: 'course-list' + }).render(); + view.onBeforeShow(); + return view; + }; + + clickPagingControl = function(titleSelector) { + $('a[title="' + titleSelector + '"]').click(); + }; + + beforeEach(function() { + setFixtures('
'); + }); + + it('renders a list of courses with number and date formatted', function() { + var view = getCourseListView(); + + moment.locale(Utils.getMomentLocale()); + + _.chain(_.zip(view.collection.models, view.$('tbody tr'))).each(function(courseAndTr) { + var course = courseAndTr[0], + tr = courseAndTr[1]; + expect($(tr).find('td.course-name-cell .course-name')).toContainText( + course.get('catalog_course_title')); + expect($(tr).find('td.course-name-cell .course-id')).toContainText(course.get('course_id')); + expect($(tr).find('td.start_date')).toContainText( + moment.utc(course.get('start_date').split('T')[0]).format('L')); + expect($(tr).find('td.end_date')).toContainText( + moment.utc(course.get('end_date').split('T')[0]).format('L')); + expect($(tr).find('td.cumulative_count')).toContainText( + Utils.localizeNumber(course.get('cumulative_count'))); + expect($(tr).find('td.count')).toContainText(Utils.localizeNumber(course.get('count'))); + expect($(tr).find('td.count_change_7_days')).toContainText( + Utils.localizeNumber(course.get('count_change_7_days'))); + expect($(tr).find('td.verified_enrollment')).toContainText( + Utils.localizeNumber(course.get('verified_enrollment'))); + }); + }); + + describe('sorting', function() { + var clickSortingHeader, + executeSortTest, + expectSortCalled, + getSortingHeaderLink; + + getSortingHeaderLink = function(headerClass) { + return $('th.' + headerClass + ' a'); + }; + + clickSortingHeader = function(headerClass) { + getSortingHeaderLink(headerClass).click(); + }; + + executeSortTest = function(field, isInitial) { + if (isInitial) { + expect(getSortingHeaderLink(field).find('span.fa')).toHaveClass('fa-sort-desc'); + } else { + expect(getSortingHeaderLink(field).find('span.fa')).toHaveClass('fa-sort'); + } + clickSortingHeader(field); + expectSortCalled(field, 'asc'); + clickSortingHeader(field); + expectSortCalled(field, 'desc'); + }; + + expectSortCalled = function(sortField, sortValue) { + expect(getSortingHeaderLink(sortField).find('span')).toHaveClass('fa-sort-' + sortValue); + }; + + beforeEach(function() { + this.view = getCourseListView(); + }); + + SpecHelpers.withConfiguration({ + catalog_course_title: ['catalog_course_title'], + start_date: ['start_date'], + end_date: ['end_date'], + cumulative_count: ['cumulative_count'], + count: ['count', true], + count_change_7_days: ['count_change_7_days'], + verified_enrollment: ['verified_enrollment'] + }, function(sortField, isInitial) { + this.sortField = sortField; + this.isInitial = isInitial; + }, function() { + it('by headers', function() { + executeSortTest(this.sortField, this.isInitial); + }); + }); + + it('goes to the first page after applying a sort', function() { + this.view = getCourseListView({}, 1); + clickPagingControl('Page 2'); + expect(this.view.$('a[title="Page 2"]').parent('li')).toHaveClass('active'); + clickSortingHeader('catalog_course_title'); + expect(this.view.$('a[title="Page 1"]').parent('li')).toHaveClass('active'); + }); + + it('triggers a tracking event', function() { + var triggerSpy = spyOn(this.view.options.trackingModel, 'trigger'), + headerClasses = [ + 'catalog_course_title', + 'start_date', + 'end_date', + 'cumulative_count', + 'count', + 'count_change_7_days', + 'verified_enrollment' + ]; + _.each(headerClasses, function(column) { + executeSortTest(column); + expect(triggerSpy).toHaveBeenCalledWith('segment:track', 'edx.bi.course_list.sorted', { + category: column + '_asc' + }); + expect(triggerSpy).toHaveBeenCalledWith('segment:track', 'edx.bi.course_list.sorted', { + category: column + '_desc' + }); + }); + }); + }); + + describe('paging', function() { + var createTwoPageView, + expectLinkStates; + + createTwoPageView = function() { + var view = getCourseListView({}, 1); + return view; + }; + + expectLinkStates = function(view, activeLinkTitle, disabledLinkTitles) { + view.$('li > a').each(function(_index, link) { + var $link = $(link), + $parentLi = $link.parent('li'); + if ($link.attr('title') === activeLinkTitle) { + expect($parentLi).toHaveClass('active'); + expect($parentLi).not.toHaveClass('disabled'); + } else if (_.contains(disabledLinkTitles, $link.attr('title'))) { + expect($parentLi).not.toHaveClass('active'); + expect($parentLi).toHaveClass('disabled'); + } else { + expect($parentLi).not.toHaveClass('active'); + expect($parentLi).not.toHaveClass('disabled'); + } + }); + }; + + it('triggers a tracking event', function() { + var view = createTwoPageView(), + triggerSpy = spyOn(view.options.trackingModel, 'trigger'); + // navigate to page 2 + clickPagingControl('Next'); + expect(triggerSpy).toHaveBeenCalledWith('segment:track', 'edx.bi.course_list.paged', { + category: 2 + }); + }); + + it('can jump to a particular page', function() { + var view = createTwoPageView(); + clickPagingControl('Page 2'); + expectLinkStates(view, 'Page 2', ['Next', 'Last']); + }); + + it('can navigate to the next/previous page', function() { + var view = createTwoPageView(); + + clickPagingControl('Next'); + expectLinkStates(view, 'Page 2', ['Next', 'Last']); + + clickPagingControl('Previous'); + expectLinkStates(view, 'Page 1', ['First', 'Previous']); + }); + + it('does not enable pagination controls for unreachable pages', function() { + var view = createTwoPageView(); + // Verify no request, no view change + clickPagingControl('Previous'); + expectLinkStates(view, 'Page 1', ['First', 'Previous']); + }); + }); + + describe('no results', function() { + it('renders a "no results" view when there is no course data in the initial collection', function() { + var view = getCourseListView({ + collection: new CourseList([]) + }); + expect(view.$('.alert-information')) + .toContainText('No course data is currently available for your course.'); + }); + }); + + describe('accessibility', function() { + it('the table has a element', function() { + var view = getCourseListView(); + expect(view.$('table > caption')).toBeInDOM(); + }); + + it('all elements have scope attributes', function() { + var view = getCourseListView(); + view.$('th').each(function(_index, $th) { + expect($th).toHaveAttr('scope', 'col'); + }); + }); + + it('all icons should be aria-hidden', function() { + var view = getCourseListView(); + view.$('i').each(function(_index, el) { + expect($(el)).toHaveAttr('aria-hidden', 'true'); + }); + }); + + it('sets focus to the top of the table after taking a paging action', function() { + var view = getCourseListView({}, 1), + firstPageLink = view.$('.backgrid-paginator li a[title="Page 1"]'), + secondPageLink = view.$('.backgrid-paginator li a[title="Page 2"]'); + // It would be ideal to use jasmine-jquery's + // expect(...).toBeFocused(), but that doesn't seem to + // be working with jQuery's focus method. A spy is + // the next-best option. + spyOn($.fn, 'focus'); + firstPageLink.click(); + // The first page link is disabled, and since we + // haven't changed pages, it should receive focus. + expect(firstPageLink.focus).toHaveBeenCalled(); + secondPageLink.click(); + // The second page link is not disabled, and after + // clicking it, we should set focus to the top of the + // table. + expect($('#course-list-app-focusable').focus).toHaveBeenCalled(); + }); + + it('does not violate the axe-core ruleset', function(done) { + getCourseListView(); + axe.a11yCheck($('.course-list-view-fixture')[0], function(result) { + expect(result.violations.length).toBe(0); + done(); + }); + }); + }); + }); +}); diff --git a/analytics_dashboard/static/apps/course-list/list/views/table.js b/analytics_dashboard/static/apps/course-list/list/views/table.js new file mode 100644 index 000000000..b51e516b4 --- /dev/null +++ b/analytics_dashboard/static/apps/course-list/list/views/table.js @@ -0,0 +1,60 @@ +/** + * 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; +}); diff --git a/analytics_dashboard/static/apps/learners/app/app.js b/analytics_dashboard/static/apps/learners/app/app.js index 5ea3906cb..2220070a7 100644 --- a/analytics_dashboard/static/apps/learners/app/app.js +++ b/analytics_dashboard/static/apps/learners/app/app.js @@ -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 diff --git a/analytics_dashboard/static/apps/learners/app/controller.js b/analytics_dashboard/static/apps/learners/app/controller.js index 867964572..0d42c63ee 100644 --- a/analytics_dashboard/static/apps/learners/app/controller.js +++ b/analytics_dashboard/static/apps/learners/app/controller.js @@ -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 }), diff --git a/analytics_dashboard/static/apps/learners/app/spec/controller-spec.js b/analytics_dashboard/static/apps/learners/app/spec/controller-spec.js index 0ca1af5bb..d44bebf6b 100644 --- a/analytics_dashboard/static/apps/learners/app/spec/controller-spec.js +++ b/analytics_dashboard/static/apps/learners/app/spec/controller-spec.js @@ -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('
'); - 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 diff --git a/analytics_dashboard/static/apps/learners/app/spec/router-spec.js b/analytics_dashboard/static/apps/learners/app/spec/router-spec.js index 649fa9c18..73bc02b64 100644 --- a/analytics_dashboard/static/apps/learners/app/spec/router-spec.js +++ b/analytics_dashboard/static/apps/learners/app/spec/router-spec.js @@ -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() { diff --git a/analytics_dashboard/static/apps/learners/app/templates/root.underscore b/analytics_dashboard/static/apps/learners/app/templates/root.underscore deleted file mode 100644 index 6cf3071ae..000000000 --- a/analytics_dashboard/static/apps/learners/app/templates/root.underscore +++ /dev/null @@ -1,5 +0,0 @@ -
-
-
-
-
diff --git a/analytics_dashboard/static/apps/learners/common/collections/learners.js b/analytics_dashboard/static/apps/learners/common/collections/learners.js index 0224b27b2..974d536f6 100644 --- a/analytics_dashboard/static/apps/learners/common/collections/learners.js +++ b/analytics_dashboard/static/apps/learners/common/collections/learners.js @@ -1,22 +1,17 @@ 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); - } - } } }); diff --git a/analytics_dashboard/static/apps/learners/common/models/course-metadata.js b/analytics_dashboard/static/apps/learners/common/models/course-metadata.js index 3a4f320ba..73a07c8ae 100644 --- a/analytics_dashboard/static/apps/learners/common/models/course-metadata.js +++ b/analytics_dashboard/static/apps/learners/common/models/course-metadata.js @@ -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) { diff --git a/analytics_dashboard/static/apps/learners/common/models/engagement-timeline.js b/analytics_dashboard/static/apps/learners/common/models/engagement-timeline.js index dcd6a1732..bac5d8579 100644 --- a/analytics_dashboard/static/apps/learners/common/models/engagement-timeline.js +++ b/analytics_dashboard/static/apps/learners/common/models/engagement-timeline.js @@ -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() { diff --git a/analytics_dashboard/static/apps/learners/common/models/learner.js b/analytics_dashboard/static/apps/learners/common/models/learner.js index d894bdf40..3f6ec739f 100644 --- a/analytics_dashboard/static/apps/learners/common/models/learner.js +++ b/analytics_dashboard/static/apps/learners/common/models/learner.js @@ -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) { diff --git a/analytics_dashboard/static/apps/learners/detail/views/learner-detail.js b/analytics_dashboard/static/apps/learners/detail/views/learner-detail.js index b38a48796..1f46bc70e 100644 --- a/analytics_dashboard/static/apps/learners/detail/views/learner-detail.js +++ b/analytics_dashboard/static/apps/learners/detail/views/learner-detail.js @@ -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); } }, diff --git a/analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js b/analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js index fb74cf905..4c7b9f4cb 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js +++ b/analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js @@ -1,95 +1,23 @@ -/** - * 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; }); diff --git a/analytics_dashboard/static/apps/learners/roster/views/controls.js b/analytics_dashboard/static/apps/learners/roster/views/controls.js index 134fb208e..19ba96f61 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/controls.js +++ b/analytics_dashboard/static/apps/learners/roster/views/controls.js @@ -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 + } + } + ]; } }); diff --git a/analytics_dashboard/static/apps/learners/roster/views/filter.js b/analytics_dashboard/static/apps/learners/roster/views/filter.js index 928b31127..de1c5c9d2 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/filter.js +++ b/analytics_dashboard/static/apps/learners/roster/views/filter.js @@ -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 }); }, diff --git a/analytics_dashboard/static/apps/learners/roster/views/results.js b/analytics_dashboard/static/apps/learners/roster/views/results.js index a4744e149..045b8b740 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/results.js +++ b/analytics_dashboard/static/apps/learners/roster/views/results.js @@ -8,7 +8,7 @@ define(function(require) { var _ = require('underscore'), Marionette = require('marionette'), - AlertView = require('learners/common/views/alert-view'), + AlertView = require('components/alert/views/alert-view'), LearnerTableView = require('learners/roster/views/table'), LearnerResultsView; @@ -29,11 +29,9 @@ define(function(require) { if (collection.length && this.options.hasData) { // Don't re-render the learner table view if one already exists. if (!(this.getRegion('main').currentView instanceof LearnerTableView)) { - this.showChildView('main', new LearnerTableView({ - collection: collection, - courseMetadata: this.options.courseMetadata, - trackingModel: this.options.trackingModel - })); + this.showChildView('main', new LearnerTableView(_.extend({ + collection: collection + }, this.options))); } } else { this.showChildView('main', this.createAlertView(collection)); diff --git a/analytics_dashboard/static/apps/learners/roster/views/roster.js b/analytics_dashboard/static/apps/learners/roster/views/roster.js index ec7066e55..b4be99422 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/roster.js +++ b/analytics_dashboard/static/apps/learners/roster/views/roster.js @@ -9,28 +9,18 @@ define(function(require) { 'use strict'; var _ = require('underscore'), - Marionette = require('marionette'), + ListView = require('components/generic-list/list/views/list'), ActiveDateRangeView = require('learners/roster/views/activity-date-range'), ActiveFiltersView = require('learners/roster/views/active-filters'), - DownloadDataView = require('learners/common/views/download-data'), + DownloadDataView = require('components/download/views/download-data'), LearnerResultsView = require('learners/roster/views/results'), - LearnerUtils = require('learners/common/utils'), RosterControlsView = require('learners/roster/views/controls'), rosterTemplate = require('text!learners/roster/templates/roster.underscore'), LearnerRosterView; - // 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. - */ - LearnerRosterView = Marionette.LayoutView.extend({ + LearnerRosterView = ListView.extend({ className: 'learner-roster', template: _.template(rosterTemplate), @@ -44,48 +34,57 @@ define(function(require) { }, initialize: function(options) { - var eventTransformers; - - this.options = options || {}; + ListView.prototype.initialize.call(this, options); - eventTransformers = { - serverError: LearnerUtils.EventTransformers.serverErrorToAppError, - networkError: LearnerUtils.EventTransformers.networkErrorToAppError, - sync: LearnerUtils.EventTransformers.syncToClearError - }; - LearnerUtils.mapEvents(this.options.collection, eventTransformers, this); - LearnerUtils.mapEvents(this.options.courseMetadata, eventTransformers, this); - }, - - onBeforeShow: function() { - this.showChildView('activeFilters', new ActiveFiltersView({ - collection: this.options.collection - })); - this.showChildView('activityDateRange', new ActiveDateRangeView({ - model: this.options.courseMetadata - })); - this.showChildView('downloadData', new DownloadDataView({ - collection: this.options.collection, - trackingModel: this.options.trackingModel, - trackCategory: 'learner_roster' - })); - this.showChildView('controls', new RosterControlsView({ - collection: this.options.collection, - courseMetadata: this.options.courseMetadata, - trackingModel: this.options.trackingModel - })); - this.showChildView('results', new LearnerResultsView({ - collection: this.options.collection, - courseMetadata: this.options.courseMetadata, - hasData: this.options.hasData, - trackingModel: this.options.trackingModel - })); - }, + this.childViews = [ + { + region: 'activeFilters', + class: ActiveFiltersView, + options: { + collection: this.options.collection + } + }, + { + region: 'activityDateRange', + class: ActiveDateRangeView, + options: { + model: this.options.courseMetadata + } + }, + { + region: 'downloadData', + class: DownloadDataView, + options: { + collection: this.options.collection, + trackingModel: this.options.trackingModel, + trackCategory: 'learner_roster' + } + }, + { + region: 'controls', + class: RosterControlsView, + options: { + collection: this.options.collection, + courseMetadata: this.options.courseMetadata, + trackingModel: this.options.trackingModel + } + }, + { + region: 'results', + class: LearnerResultsView, + options: { + collection: this.options.collection, + courseMetadata: this.options.courseMetadata, + hasData: this.options.hasData, + trackingModel: this.options.trackingModel, + tableName: this.options.tableName, + trackSubject: this.options.trackSubject, + appClass: this.options.appClass + } + } + ]; - templateHelpers: function() { - return { - controlsLabel: gettext('Learner roster controls') - }; + this.controlsLabel = gettext('Learner roster controls'); } }); diff --git a/analytics_dashboard/static/apps/learners/roster/views/spec/roster-spec.js b/analytics_dashboard/static/apps/learners/roster/views/spec/roster-spec.js index 9571e21e9..0ba485087 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/spec/roster-spec.js +++ b/analytics_dashboard/static/apps/learners/roster/views/spec/roster-spec.js @@ -63,6 +63,7 @@ define(function(require) { courseMetadata: defaultOptions.courseMetadataModel || new CourseMetadataModel(defaultOptions.courseMetadata, {parse: true}), el: '.' + fixtureClass, + trackSubject: 'roster', hasData: true, trackingModel: new TrackingModel() }).render(); diff --git a/analytics_dashboard/static/apps/learners/roster/views/table.js b/analytics_dashboard/static/apps/learners/roster/views/table.js index 75c150127..2a06c141a 100644 --- a/analytics_dashboard/static/apps/learners/roster/views/table.js +++ b/analytics_dashboard/static/apps/learners/roster/views/table.js @@ -6,11 +6,10 @@ define(function(require) { var _ = require('underscore'), Backgrid = require('backgrid'), - Marionette = require('marionette'), + ListTableView = require('components/generic-list/list/views/table'), BaseHeaderCell = require('learners/roster/views/base-header-cell'), NameAndUsernameCell = require('learners/roster/views/name-username-cell'), - PagingFooter = require('learners/roster/views/paging-footer'), Utils = require('utils/utils'), learnerTableTemplate = require('text!learners/roster/templates/table.underscore'), @@ -75,55 +74,29 @@ define(function(require) { className: 'learner-engagement-cell' }); - LearnerTableView = Marionette.LayoutView.extend({ + LearnerTableView = ListTableView.extend({ template: _.template(learnerTableTemplate), regions: { table: '.learners-table', paginator: '.learners-paging-footer' }, - initialize: function(options) { - this.options = options || {}; - this.collection.on('backgrid:sort', this.onSort, this); - }, - onSort: function(column, direction) { - this.options.trackingModel.trigger('segment:track', 'edx.bi.roster.sorted', { - category: column.get('name') + '_' + direction.slice(0, -6) - }); - }, - onBeforeShow: function() { + buildColumns: function() { var options = this.options; - this.showChildView('table', new Backgrid.Grid({ - className: 'table table-striped dataTable', // Combine bootstrap and datatables styling - collection: this.options.collection, - columns: _.map(this.options.collection.sortableFields, function(val, key) { - var column = { - label: val.displayName, - name: key, - editable: false, - sortable: true, - sortType: 'toggle' - }; - - if (key === 'username') { - column.cell = NameAndUsernameCell; - column.headerCell = BaseHeaderCell; - } else { - options = _.defaults({ - significantDigits: key === 'problem_attempts_per_completed' ? 1 : 0 - }, options); - column.cell = createEngagementCell(key, options); - column.headerCell = EngagementHeaderCell; - } + return _.map(this.options.collection.sortableFields, function(val, key) { + var column = this.createDefaultColumn(val.displayName, key); + if (key === 'username') { + column.cell = NameAndUsernameCell; + column.headerCell = BaseHeaderCell; + } else { + options = _.defaults({ + significantDigits: key === 'problem_attempts_per_completed' ? 1 : 0 + }, options); + column.cell = createEngagementCell(key, options); + column.headerCell = EngagementHeaderCell; + } - return column; - }) - })); - this.showChildView('paginator', new PagingFooter({ - collection: this.options.collection, - trackingModel: this.options.trackingModel - })); - // Accessibility hacks - this.$('table').prepend('' + gettext('Learner Roster') + ''); + return column; + }, this); } }); diff --git a/analytics_dashboard/static/js/config.js b/analytics_dashboard/static/js/config.js index a13c8224b..a0899250c 100644 --- a/analytics_dashboard/static/js/config.js +++ b/analytics_dashboard/static/js/config.js @@ -16,6 +16,7 @@ require.config({ backgrid: 'bower_components/backgrid/lib/backgrid', 'backgrid-filter': 'bower_components/backgrid-filter/backgrid-filter.min', 'backgrid-paginator': 'bower_components/backgrid-paginator/backgrid-paginator.min', + 'backgrid-moment-cell': 'bower_components/backgrid-moment-cell/backgrid-moment-cell.min', bootstrap: 'bower_components/bootstrap-sass-official/assets/javascripts/bootstrap', bootstrap_accessibility: 'bower_components/bootstrapaccessibilityplugin/plugins/js/bootstrap-accessibility', models: 'js/models', @@ -45,6 +46,8 @@ require.config({ punycode: 'bower_components/uri.js/src/punycode', SecondLevelDomains: 'bower_components/uri.js/src/SecondLevelDomains', learners: 'apps/learners', + 'course-list': 'apps/course-list', + components: 'apps/components', 'axe-core': 'bower_components/axe-core/axe.min', sinon: 'bower_components/sinon/lib/sinon', nprogress: 'bower_components/nprogress/nprogress' @@ -81,6 +84,9 @@ require.config({ 'backgrid-paginator': { deps: ['backbone', 'underscore', 'jquery', 'backgrid'] }, + 'backgrid-moment-cell': { + deps: ['backbone', 'underscore', 'moment', 'backgrid'] + }, dataTablesBootstrap: { deps: ['jquery', 'datatables'] }, diff --git a/analytics_dashboard/static/js/utils/utils.js b/analytics_dashboard/static/js/utils/utils.js index 3f406f281..8ef32f611 100644 --- a/analytics_dashboard/static/js/utils/utils.js +++ b/analytics_dashboard/static/js/utils/utils.js @@ -32,20 +32,25 @@ define(['moment', 'underscore', 'utils/globalization'], function(moment, _, Glob return properties; }, + + getMomentLocale: function() { + // moment accepts 'zh-cn' rather than 'zh' and 'zh-tw' rather than 'zh-hant' + if (window.language === 'zh') { + return 'zh-cn'; + } else if (window.language === 'zh-Hant') { + return 'zh-tw'; + } else { + return window.language; + } + }, + /** * Takes a standard string date and returns a formatted date. * @param {(string|Date)} date (ex. 2014-01-31 or Date object) * @returns {string} Returns a formatted date (ex. January 31, 2014) */ formatDate: function(date) { - // moment accepts 'zh-cn' rather than 'zh' and 'zh-tw' rather than 'zh-hant' - if (window.language === 'zh') { - moment.locale('zh-cn'); - } else if (window.language === 'zh-Hant') { - moment.locale('zh-tw'); - } else { - moment.locale(window.language); - } + moment.locale(this.getMomentLocale()); return moment.utc(date).format('LL'); }, diff --git a/analytics_dashboard/static/sass/_developer.scss b/analytics_dashboard/static/sass/_developer.scss index 62956cefb..5d6404f65 100644 --- a/analytics_dashboard/static/sass/_developer.scss +++ b/analytics_dashboard/static/sass/_developer.scss @@ -260,8 +260,6 @@ $lens-nav-margin: 15px; margin-top: 10px; margin-bottom: 15px; background: white; - border: 1px solid $gray; - border-width: 0 0 1px 1px; .course { padding: 15px; @@ -276,6 +274,19 @@ $lens-nav-margin: 15px; color: $edx-gray; } } + + .integer-cell { + text-align: right; + } + + .course-list-table th.sortable { + min-width: 100px; + } + + .course-id { + color: $edx-gray; + } + } body.view-course-list { diff --git a/analytics_dashboard/templates/base_dashboard.html b/analytics_dashboard/templates/base_dashboard.html index 92ba5bb1b..66fe4cfdc 100644 --- a/analytics_dashboard/templates/base_dashboard.html +++ b/analytics_dashboard/templates/base_dashboard.html @@ -43,7 +43,7 @@

{{ page_title }}

{% endblock %}
-
+

{% block intro-text %}{% endblock intro-text %}

diff --git a/bower.json b/bower.json index 26bd7b9de..a24ff389c 100644 --- a/bower.json +++ b/bower.json @@ -21,7 +21,7 @@ "requirejs": "~2.1.15", "nvd3": "~1.8.4", "jasmine": "~2.0.4", - "moment": "~2.8.3", + "moment": "~2.17.1", "topojson": "~1.4.3", "datamaps": "~0.3.4", "datatables": "~1.10.2", @@ -32,6 +32,7 @@ "marionette": "~2.4.4", "uri.js": "1.17", "backgrid": "^0.3.5", + "backgrid-moment-cell": "^0.3.8", "backgrid-paginator": "^0.3.5", "backgrid-filter": "^0.3.5", "text": "^2.0.14", @@ -44,6 +45,7 @@ "jquery": "~1.11.1", "underscore": "~1.8.2", "d3": "~3.5.3", - "cldrjs": "~0.4.4" + "cldrjs": "~0.4.4", + "moment": "~2.17.1" } } diff --git a/build.js b/build.js index 982551a71..1da8876aa 100644 --- a/build.js +++ b/build.js @@ -79,6 +79,10 @@ { name: 'js/performance-learning-outcomes-section-main', exclude: ['js/common'] + }, + { + name: 'apps/course-list/app/course-list-main', + exclude: ['js/common'] } ] }); diff --git a/requirements/base.txt b/requirements/base.txt index 600b1e240..c5e137b72 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -26,7 +26,7 @@ python-social-auth==0.2.19 requests==2.10.0 # Apache 2.0 git+https://github.com/edx/django-lang-pref-middleware.git@0.1.0#egg=django-lang-pref-middleware -git+https://github.com/edx/edx-analytics-data-api-client.git@0.9.0#egg=edx-analytics-data-api-client==0.9.0 # edX +git+https://github.com/edx/edx-analytics-data-api-client.git@0.10.0#egg=edx-analytics-data-api-client==0.10.0 # edX git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys # custom opaque-key implementations for ccx git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys