diff --git a/common/static/common/js/components/views/paginated_view.js b/common/static/common/js/components/views/paginated_view.js new file mode 100644 index 0000000000..b90213ab59 --- /dev/null +++ b/common/static/common/js/components/views/paginated_view.js @@ -0,0 +1,43 @@ +;(function(define) { + 'use strict'; + define([ + 'backbone', + 'underscore', + 'common/js/components/views/paging_header', + 'common/js/components/views/paging_footer', + 'common/js/components/views/list', + 'text!common/templates/components/paginated-view.underscore' + ], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) { + var PaginatedView = Backbone.View.extend({ + initialize: function () { + var ItemListView = ListView.extend({ + tagName: 'div', + className: this.type + '-container', + itemViewClass: this.itemViewClass + }); + this.listView = new ItemListView({collection: this.options.collection}); + this.headerView = this.headerView = new PagingHeader({collection: this.options.collection}); + this.footerView = new PagingFooter({ + collection: this.options.collection, hideWhenOnePage: true + }); + this.collection.on('page_changed', function () { + this.$('.sr-is-focusable.sr-' + this.type + '-view').focus(); + }, this); + }, + + render: function () { + this.$el.html(_.template(paginatedViewTemplate, {type: this.type})); + this.assign(this.listView, '.' + this.type + '-list'); + this.assign(this.headerView, '.' + this.type + '-paging-header'); + this.assign(this.footerView, '.' + this.type + '-paging-footer'); + return this; + }, + + assign: function (view, selector) { + view.setElement(this.$(selector)).render(); + } + }); + + return PaginatedView; + }); +}).call(this, define || RequireJS.define); diff --git a/common/static/common/js/components/views/paging_footer.js b/common/static/common/js/components/views/paging_footer.js index 08f3257434..36d2c74f2a 100644 --- a/common/static/common/js/components/views/paging_footer.js +++ b/common/static/common/js/components/views/paging_footer.js @@ -23,7 +23,8 @@ var onFirstPage = !this.collection.hasPreviousPage(), onLastPage = !this.collection.hasNextPage(); if (this.hideWhenOnePage) { - if (this.collection.totalPages <= 1) { + if (_.isUndefined(this.collection.totalPages) + || this.collection.totalPages <= 1) { this.$el.addClass('hidden'); } else if (this.$el.hasClass('hidden')) { this.$el.removeClass('hidden'); diff --git a/common/static/common/js/components/views/paging_header.js b/common/static/common/js/components/views/paging_header.js index 3489b01b0f..3bed5147b1 100644 --- a/common/static/common/js/components/views/paging_header.js +++ b/common/static/common/js/components/views/paging_header.js @@ -16,9 +16,9 @@ render: function () { var message, - start = this.collection.start, + start = _.isUndefined(this.collection.start) ? 0 : this.collection.start, end = start + this.collection.length, - num_items = this.collection.totalCount, + num_items = _.isUndefined(this.collection.totalCount) ? 0 : this.collection.totalCount, context = {first_index: Math.min(start + 1, end), last_index: end, num_items: num_items}; if (end <= 1) { message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true); diff --git a/common/static/common/js/spec/components/paginated_view_spec.js b/common/static/common/js/spec/components/paginated_view_spec.js new file mode 100644 index 0000000000..4e04ede8a6 --- /dev/null +++ b/common/static/common/js/spec/components/paginated_view_spec.js @@ -0,0 +1,184 @@ +define([ + 'backbone', + 'underscore', + 'common/js/spec_helpers/ajax_helpers', + 'common/js/components/views/paginated_view', + 'common/js/components/collections/paging_collection' +], function (Backbone, _, AjaxHelpers, PaginatedView, PagingCollection) { + 'use strict'; + describe('PaginatedView', function () { + var TestItemView = Backbone.View.extend({ + className: 'test-item', + tagName: 'div', + initialize: function () { + this.render(); + }, + render: function () { + this.$el.text(this.model.get('text')); + return this; + } + }), + TestPaginatedView = PaginatedView.extend({type: 'test', itemViewClass: TestItemView}), + testCollection, + testView, + initialItems, + nextPageButtonCss = '.next-page-link', + previousPageButtonCss = '.previous-page-link', + generateItems = function (numItems) { + return _.map(_.range(numItems), function (i) { + return { + text: 'item ' + i + }; + }); + }; + + beforeEach(function () { + setFixtures('
'); + initialItems = generateItems(5); + testCollection = new PagingCollection({ + count: 6, + num_pages: 2, + current_page: 1, + start: 0, + results: initialItems + }, {parse: true}); + testView = new TestPaginatedView({el: '.test-container', collection: testCollection}).render(); + }); + + /** + * Verify that the view's header reflects the page we're currently viewing. + * @param matchString the header we expect to see + */ + function expectHeader(matchString) { + expect(testView.$('.test-paging-header').text()).toMatch(matchString); + } + + /** + * Verify that the list view renders the expected items + * @param expectedItems an array of topic objects we expect to see + */ + function expectItems(expectedItems) { + var $items = testView.$('.test-item'); + _.each(expectedItems, function (item, index) { + var currentItem = $items.eq(index); + expect(currentItem.text()).toMatch(item.text); + }); + } + + /** + * Verify that the footer reflects the current pagination + * @param options a parameters hash containing: + * - currentPage: the one-indexed page we expect to be viewing + * - totalPages: the total number of pages to page through + * - isHidden: whether the footer is expected to be visible + */ + function expectFooter(options) { + var footerEl = testView.$('.test-paging-footer'); + expect(footerEl.text()) + .toMatch(new RegExp(options.currentPage + '\\s+out of\\s+\/\\s+' + testCollection.totalPages)); + expect(footerEl.hasClass('hidden')).toBe(options.isHidden); + } + + it('can render the first of many pages', function () { + expectHeader('Showing 1-5 out of 6 total'); + expectItems(initialItems); + expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); + }); + + it('can render the only page', function () { + initialItems = generateItems(1); + testCollection.set( + { + "count": 1, + "num_pages": 1, + "current_page": 1, + "start": 0, + "results": initialItems + }, + {parse: true} + ); + expectHeader('Showing 1 out of 1 total'); + expectItems(initialItems); + expectFooter({currentPage: 1, totalPages: 1, isHidden: true}); + }); + + it('can change to the next page', function () { + var requests = AjaxHelpers.requests(this), + newItems = generateItems(1); + expectHeader('Showing 1-5 out of 6 total'); + expectItems(initialItems); + expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); + expect(requests.length).toBe(0); + testView.$(nextPageButtonCss).click(); + expect(requests.length).toBe(1); + AjaxHelpers.respondWithJson(requests, { + "count": 6, + "num_pages": 2, + "current_page": 2, + "start": 5, + "results": newItems + }); + expectHeader('Showing 6-6 out of 6 total'); + expectItems(newItems); + expectFooter({currentPage: 2, totalPages: 2, isHidden: false}); + }); + + it('can change to the previous page', function () { + var requests = AjaxHelpers.requests(this), + previousPageItems; + initialItems = generateItems(1); + testCollection.set( + { + "count": 6, + "num_pages": 2, + "current_page": 2, + "start": 5, + "results": initialItems + }, + {parse: true} + ); + expectHeader('Showing 6-6 out of 6 total'); + expectItems(initialItems); + expectFooter({currentPage: 2, totalPages: 2, isHidden: false}); + testView.$(previousPageButtonCss).click(); + previousPageItems = generateItems(5); + AjaxHelpers.respondWithJson(requests, { + "count": 6, + "num_pages": 2, + "current_page": 1, + "start": 0, + "results": previousPageItems + }); + expectHeader('Showing 1-5 out of 6 total'); + expectItems(previousPageItems); + expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); + }); + + it('sets focus for screen readers', function () { + var requests = AjaxHelpers.requests(this); + spyOn($.fn, 'focus'); + testView.$(nextPageButtonCss).click(); + AjaxHelpers.respondWithJson(requests, { + "count": 6, + "num_pages": 2, + "current_page": 2, + "start": 5, + "results": generateItems(1) + }); + expect(testView.$('.sr-is-focusable').focus).toHaveBeenCalled(); + }); + + it('does not change on server error', function () { + var requests = AjaxHelpers.requests(this), + expectInitialState = function () { + expectHeader('Showing 1-5 out of 6 total'); + expectItems(initialItems); + expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); + }; + expectInitialState(); + testView.$(nextPageButtonCss).click(); + requests[0].respond(500); + expectInitialState(); + }); + }); +}); diff --git a/common/static/common/templates/components/paginated-view.underscore b/common/static/common/templates/components/paginated-view.underscore new file mode 100644 index 0000000000..09ea0a13b4 --- /dev/null +++ b/common/static/common/templates/components/paginated-view.underscore @@ -0,0 +1,4 @@ + + + + diff --git a/common/static/js/spec/main_requirejs.js b/common/static/js/spec/main_requirejs.js index 42a204e74a..ac83443fe6 100644 --- a/common/static/js/spec/main_requirejs.js +++ b/common/static/js/spec/main_requirejs.js @@ -156,6 +156,7 @@ define([ // Run the common tests that use RequireJS. 'common-requirejs/include/common/js/spec/components/list_spec.js', + 'common-requirejs/include/common/js/spec/components/paginated_view_spec.js', 'common-requirejs/include/common/js/spec/components/paging_collection_spec.js', 'common-requirejs/include/common/js/spec/components/paging_header_spec.js', 'common-requirejs/include/common/js/spec/components/paging_footer_spec.js' diff --git a/common/test/acceptance/pages/lms/teams.py b/common/test/acceptance/pages/lms/teams.py index de6b81c215..ce224ebb9c 100644 --- a/common/test/acceptance/pages/lms/teams.py +++ b/common/test/acceptance/pages/lms/teams.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Teams page. +Teams pages. """ from .course_page import CoursePage @@ -9,6 +9,8 @@ from ..common.paging import PaginatedUIMixin TOPIC_CARD_CSS = 'div.wrapper-card-core' BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]' +TEAMS_LINK_CSS = '.action-view' +TEAMS_HEADER_CSS = '.teams-header' class TeamsPage(CoursePage): @@ -53,3 +55,50 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): def topic_cards(self): """Return a list of the topic cards present on the page.""" return self.q(css=TOPIC_CARD_CSS).results + + def browse_teams_for_topic(self, topic_name): + """ + Show the teams list for `topic_name`. + """ + self.q(css=TEAMS_LINK_CSS).filter( + text='View Teams in the {topic_name} Topic'.format(topic_name=topic_name) + )[0].click() + self.wait_for_ajax() + + +class BrowseTeamsPage(CoursePage, PaginatedUIMixin): + """ + The paginated UI for browsing teams within a Topic on the Teams + page. + """ + def __init__(self, browser, course_id, topic): + """ + Set up `self.url_path` on instantiation, since it dynamically + reflects the current topic. Note that `topic` is a dict + representation of a topic following the same convention as a + course module's topic. + """ + super(BrowseTeamsPage, self).__init__(browser, course_id) + self.topic = topic + self.url_path = "teams/#topics/{topic_id}".format(topic_id=self.topic['id']) + + def is_browser_on_page(self): + """Check if we're on the teams list page for a particular topic.""" + has_correct_url = self.url.endswith(self.url_path) + teams_list_view_present = self.q(css='.teams-main').present + return has_correct_url and teams_list_view_present + + @property + def header_topic_name(self): + """Get the topic name displayed by the page header""" + return self.q(css=TEAMS_HEADER_CSS + ' .page-title')[0].text + + @property + def header_topic_description(self): + """Get the topic description displayed by the page header""" + return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text + + @property + def team_cards(self): + """Get all the team cards on the page.""" + return self.q(css='.team-card') diff --git a/common/test/acceptance/tests/lms/test_teams.py b/common/test/acceptance/tests/lms/test_teams.py index a212dd30b4..ae1f9917cf 100644 --- a/common/test/acceptance/tests/lms/test_teams.py +++ b/common/test/acceptance/tests/lms/test_teams.py @@ -1,23 +1,23 @@ """ Acceptance tests for the teams feature. """ -from ..helpers import UniqueCourseTest -from ...pages.lms.teams import TeamsPage, BrowseTopicsPage +import json + from nose.plugins.attrib import attr + +from ..helpers import UniqueCourseTest +from ...pages.lms.teams import TeamsPage, BrowseTopicsPage, BrowseTeamsPage +from ...fixtures import LMS_BASE_URL from ...fixtures.course import CourseFixture from ...pages.lms.tab_nav import TabNavPage from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.course_info import CourseInfoPage -@attr('shard_5') -class TeamsTabTest(UniqueCourseTest): - """ - Tests verifying when the Teams tab is present. - """ - +class TeamsTabBase(UniqueCourseTest): + """Base class for Teams Tab tests""" def setUp(self): - super(TeamsTabTest, self).setUp() + super(TeamsTabBase, self).setUp() self.tab_nav = TabNavPage(self.browser) self.course_info_page = CourseInfoPage(self.browser, self.course_id) self.teams_page = TeamsPage(self.browser, self.course_id) @@ -39,7 +39,8 @@ class TeamsTabTest(UniqueCourseTest): self.course_fixture.install() enroll_course_id = self.course_id if enroll_in_course else None - AutoAuthPage(self.browser, course_id=enroll_course_id, staff=global_staff).visit() + #pylint: disable=attribute-defined-outside-init + self.user_info = AutoAuthPage(self.browser, course_id=enroll_course_id, staff=global_staff).visit().user_info self.course_info_page.visit() def verify_teams_present(self, present): @@ -54,6 +55,12 @@ class TeamsTabTest(UniqueCourseTest): else: self.assertNotIn("Teams", self.tab_nav.tab_names) + +@attr('shard_5') +class TeamsTabTest(TeamsTabBase): + """ + Tests verifying when the Teams tab is present. + """ def test_teams_not_enabled(self): """ Scenario: teams tab should not be present if no team configuration is set @@ -118,7 +125,7 @@ class TeamsTabTest(UniqueCourseTest): @attr('shard_5') -class BrowseTopicsTest(TeamsTabTest): +class BrowseTopicsTest(TeamsTabBase): """ Tests for the Browse tab of the Teams page. """ @@ -228,3 +235,223 @@ class BrowseTopicsTest(TeamsTabTest): self.assertLess(len(truncated_description), len(initial_description)) self.assertTrue(truncated_description.endswith('...')) self.assertIn(truncated_description.split('...')[0], initial_description) + + def test_go_to_teams_list(self): + """ + Scenario: Clicking on a Topic Card should take you to the + teams list for that Topic. + Given I am enrolled in a course with a team configuration and a topic + When I visit the Teams page + And I browse topics + And I click on the arrow link to view teams for the first topic + Then I should be on the browse teams page + """ + topic = {u"name": u"Example Topic", u"id": u"example_topic", u"description": "Description"} + self.set_team_configuration( + {u"max_team_size": 1, u"topics": [topic]} + ) + self.topics_page.visit() + self.topics_page.browse_teams_for_topic('Example Topic') + browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic) + self.assertTrue(browse_teams_page.is_browser_on_page()) + self.assertEqual(browse_teams_page.header_topic_name, 'Example Topic') + self.assertEqual(browse_teams_page.header_topic_description, 'Description') + + +@attr('shard_5') +class BrowseTeamsWithinTopicTest(TeamsTabBase): + """ + Tests for browsing Teams within a Topic on the Teams page. + """ + TEAMS_PAGE_SIZE = 10 + + def setUp(self): + super(BrowseTeamsWithinTopicTest, self).setUp() + self.topic = {u"name": u"Example Topic", u"id": "example_topic", u"description": "Description"} + self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}) + self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic) + + def create_teams(self, num_teams): + """Create `num_teams` teams belonging to `self.topic`.""" + teams = [] + for i in xrange(num_teams): + team = { + 'course_id': self.course_id, + 'topic_id': self.topic['id'], + 'name': 'Team {}'.format(i), + 'description': 'Description {}'.format(i) + } + response = self.course_fixture.session.post( + LMS_BASE_URL + '/api/team/v0/teams/', + data=json.dumps(team), + headers=self.course_fixture.headers + ) + teams.append(json.loads(response.text)) + return teams + + def create_membership(self, username, team_id): + """Assign `username` to `team_id`.""" + response = self.course_fixture.session.post( + LMS_BASE_URL + '/api/team/v0/team_membership/', + data=json.dumps({'username': username, 'team_id': team_id}), + headers=self.course_fixture.headers + ) + return json.loads(response.text) + + def verify_page_header(self): + """Verify that the page header correctly reflects the current topic's name and description.""" + self.assertEqual(self.browse_teams_page.header_topic_name, self.topic['name']) + self.assertEqual(self.browse_teams_page.header_topic_description, self.topic['description']) + + def verify_teams(self, expected_teams): + """Verify that the list of team cards on the current page match the expected teams in order.""" + + def assert_team_equal(expected_team, team_card_name, team_card_description): + """ + Helper to assert that a single team card has the expected name and + description. + """ + self.assertEqual(expected_team['name'], team_card_name) + self.assertEqual(expected_team['description'], team_card_description) + + team_cards = self.browse_teams_page.team_cards + team_card_names = [ + team_card.find_element_by_css_selector('.card-title').text + for team_card in team_cards.results + ] + team_card_descriptions = [ + team_card.find_element_by_css_selector('.card-description').text + for team_card in team_cards.results + ] + map(assert_team_equal, expected_teams, team_card_names, team_card_descriptions) + + def verify_on_page(self, page_num, total_teams, pagination_header_text, footer_visible): + """ + Verify that we are on the correct team list page. + + Arguments: + page_num (int): The one-indexed page we expect to be on + total_teams (list): An unsorted list of all the teams for the + current topic + pagination_header_text (str): Text we expect to see in the + pagination header. + footer_visible (bool): Whether we expect to see the pagination + footer controls. + """ + alphabetized_teams = sorted(total_teams, key=lambda team: team['name']) + self.assertEqual(self.browse_teams_page.get_pagination_header_text(), pagination_header_text) + self.verify_teams(alphabetized_teams[(page_num - 1) * self.TEAMS_PAGE_SIZE:page_num * self.TEAMS_PAGE_SIZE]) + self.assertEqual( + self.browse_teams_page.pagination_controls_visible(), + footer_visible, + msg='Expected paging footer to be ' + 'visible' if footer_visible else 'invisible' + ) + + def test_no_teams(self): + """ + Scenario: Visiting a topic with no teams should not display any teams. + Given I am enrolled in a course with a team configuration and a topic + When I visit the Teams page for that topic + Then I should see the correct page header + And I should see a pagination header showing no teams + And I should see no teams + And I should see a button to add a team + And I should not see a pagination footer + """ + self.browse_teams_page.visit() + self.verify_page_header() + self.assertEqual(self.browse_teams_page.get_pagination_header_text(), 'Showing 0 out of 0 total') + self.assertEqual(len(self.browse_teams_page.team_cards), 0, msg='Expected to see no team cards') + self.assertFalse( + self.browse_teams_page.pagination_controls_visible(), + msg='Expected paging footer to be invisible' + ) + + def test_teams_one_page(self): + """ + Scenario: Visiting a topic with fewer teams than the page size should + all those teams on one page. + Given I am enrolled in a course with a team configuration and a topic + When I visit the Teams page for that topic + Then I should see the correct page header + And I should see a pagination header showing the number of teams + And I should see all the expected team cards + And I should see a button to add a team + And I should not see a pagination footer + """ + teams = self.create_teams(self.TEAMS_PAGE_SIZE) + self.browse_teams_page.visit() + self.verify_page_header() + self.assertEqual(self.browse_teams_page.get_pagination_header_text(), 'Showing 1-10 out of 10 total') + self.verify_teams(teams) + self.assertFalse( + self.browse_teams_page.pagination_controls_visible(), + msg='Expected paging footer to be invisible' + ) + + def test_teams_navigation_buttons(self): + """ + Scenario: The user should be able to page through a topic's team list + using navigation buttons when it is longer than the page size. + Given I am enrolled in a course with a team configuration and a topic + When I visit the Teams page for that topic + Then I should see the correct page header + And I should see that I am on the first page of results + When I click on the next page button + Then I should see that I am on the second page of results + And when I click on the previous page button + Then I should see that I am on the first page of results + """ + teams = self.create_teams(self.TEAMS_PAGE_SIZE + 1) + self.browse_teams_page.visit() + self.verify_page_header() + self.verify_on_page(1, teams, 'Showing 1-10 out of 11 total', True) + self.browse_teams_page.press_next_page_button() + self.verify_on_page(2, teams, 'Showing 11-11 out of 11 total', True) + self.browse_teams_page.press_previous_page_button() + self.verify_on_page(1, teams, 'Showing 1-10 out of 11 total', True) + + def test_teams_page_input(self): + """ + Scenario: The user should be able to page through a topic's team list + using the page input when it is longer than the page size. + Given I am enrolled in a course with a team configuration and a topic + When I visit the Teams page for that topic + Then I should see the correct page header + And I should see that I am on the first page of results + When I input the second page + Then I should see that I am on the second page of results + When I input the first page + Then I should see that I am on the first page of results + """ + teams = self.create_teams(self.TEAMS_PAGE_SIZE + 10) + self.browse_teams_page.visit() + self.verify_page_header() + self.verify_on_page(1, teams, 'Showing 1-10 out of 20 total', True) + self.browse_teams_page.go_to_page(2) + self.verify_on_page(2, teams, 'Showing 11-20 out of 20 total', True) + self.browse_teams_page.go_to_page(1) + self.verify_on_page(1, teams, 'Showing 1-10 out of 20 total', True) + + def test_teams_membership(self): + """ + Scenario: Team cards correctly reflect membership of the team. + Given I am enrolled in a course with a team configuration and a topic + containing one team + And I add myself to the team + When I visit the Teams page for that topic + Then I should see the correct page header + And I should see the team for that topic + And I should see that the team card shows my membership + """ + teams = self.create_teams(1) + self.browse_teams_page.visit() + self.verify_page_header() + self.verify_teams(teams) + self.create_membership(self.user_info['username'], teams[0]['id']) + self.browser.refresh() + self.browse_teams_page.wait_for_ajax() + self.assertEqual( + self.browse_teams_page.team_cards[0].find_element_by_css_selector('.member-count').text, + '1 / 10 Members' + ) diff --git a/lms/djangoapps/teams/api_urls.py b/lms/djangoapps/teams/api_urls.py index ab7556505f..f27f0af8af 100644 --- a/lms/djangoapps/teams/api_urls.py +++ b/lms/djangoapps/teams/api_urls.py @@ -39,7 +39,7 @@ urlpatterns = patterns( name="topics_detail" ), url( - r'^v0/team_membership$', + r'^v0/team_membership/$', MembershipListView.as_view(), name="team_membership_list" ), diff --git a/lms/djangoapps/teams/serializers.py b/lms/djangoapps/teams/serializers.py index 90d12f86de..486d6914c7 100644 --- a/lms/djangoapps/teams/serializers.py +++ b/lms/djangoapps/teams/serializers.py @@ -1,12 +1,16 @@ """Defines serializers used by the Team API.""" from django.contrib.auth.models import User +from django.db.models import Count + from rest_framework import serializers -from openedx.core.lib.api.serializers import CollapsedReferenceSerializer + +from openedx.core.lib.api.serializers import CollapsedReferenceSerializer, PaginationSerializer from openedx.core.lib.api.fields import ExpandableField -from .models import CourseTeam, CourseTeamMembership from openedx.core.djangoapps.user_api.serializers import UserSerializer +from .models import CourseTeam, CourseTeamMembership + class UserMembershipSerializer(serializers.ModelSerializer): """Serializes CourseTeamMemberships with only user and date_joined @@ -108,8 +112,43 @@ class MembershipSerializer(serializers.ModelSerializer): read_only_fields = ("date_joined",) -class TopicSerializer(serializers.Serializer): - """Serializes a topic.""" +class BaseTopicSerializer(serializers.Serializer): + """Serializes a topic without team_count.""" description = serializers.CharField() name = serializers.CharField() id = serializers.CharField() # pylint: disable=invalid-name + + +class TopicSerializer(BaseTopicSerializer): + """ + Adds team_count to the basic topic serializer. Use only when + serializing a single topic. When serializing many topics, use + `PaginatedTopicSerializer` to avoid O(N) SQL queries. + """ + team_count = serializers.SerializerMethodField('get_team_count') + + def get_team_count(self, topic): + """Get the number of teams associated with this topic""" + return CourseTeam.objects.filter(topic_id=topic['id']).count() + + +class PaginatedTopicSerializer(PaginationSerializer): + """Serializes a set of topics. Adds team_count field to each topic.""" + class Meta(object): + """Defines meta information for the PaginatedTopicSerializer.""" + object_serializer_class = BaseTopicSerializer + + def __init__(self, *args, **kwargs): + """Adds team_count to each topic.""" + super(PaginatedTopicSerializer, self).__init__(*args, **kwargs) + + # The following query gets all the team_counts for each topic + # and outputs the result as a list of dicts (one per topic). + topic_ids = [topic['id'] for topic in self.data['results']] + teams_per_topic = CourseTeam.objects.filter( + topic_id__in=topic_ids + ).values('topic_id').annotate(team_count=Count('topic_id')) + + topics_to_team_count = {d['topic_id']: d['team_count'] for d in teams_per_topic} + for topic in self.data['results']: + topic['team_count'] = topics_to_team_count.get(topic['id'], 0) diff --git a/lms/djangoapps/teams/static/teams/js/collections/team.js b/lms/djangoapps/teams/static/teams/js/collections/team.js index a07190be5b..2033d7e4f7 100644 --- a/lms/djangoapps/teams/static/teams/js/collections/team.js +++ b/lms/djangoapps/teams/static/teams/js/collections/team.js @@ -3,12 +3,14 @@ define(['common/js/components/collections/paging_collection', 'teams/js/models/team', 'gettext'], function(PagingCollection, TeamModel, gettext) { var TeamCollection = PagingCollection.extend({ - initialize: function(options) { + initialize: function(teams, options) { PagingCollection.prototype.initialize.call(this); this.course_id = options.course_id; + this.server_api['topic_id'] = this.topic_id = options.topic_id; + this.perPage = options.per_page; this.server_api['course_id'] = function () { return encodeURIComponent(this.course_id); }; - this.server_api['order_by'] = function () { return this.sortField; }; + this.server_api['order_by'] = function () { return 'name'; }; // TODO surface sort order in UI delete this.server_api['sort_order']; // Sort order is not specified for the Team API this.registerSortableField('name', gettext('name')); diff --git a/lms/djangoapps/teams/static/teams/js/spec/teams_factory_spec.js b/lms/djangoapps/teams/static/teams/js/spec/teams_factory_spec.js index eb5709354a..c81302167d 100644 --- a/lms/djangoapps/teams/static/teams/js/spec/teams_factory_spec.js +++ b/lms/djangoapps/teams/static/teams/js/spec/teams_factory_spec.js @@ -7,7 +7,13 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"], beforeEach(function() { setFixtures(''); - teamsTab = new TeamsTabFactory($(".teams-content"), {results: []}, '', 'edX/DemoX/Demo_Course'); + teamsTab = new TeamsTabFactory({ + topics: {results: []}, + topics_url: '', + teams_url: '', + maxTeamSize: 9999 + course_id: 'edX/DemoX/Demo_Course' + }); }); afterEach(function() { @@ -19,7 +25,7 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"], }); it("displays a header", function() { - expect($("body").html()).toContain("Course teams are organized"); + expect($("body").html()).toContain("See all teams in your course, organized by topic"); }); }); } diff --git a/lms/djangoapps/teams/static/teams/js/spec/teams_spec.js b/lms/djangoapps/teams/static/teams/js/spec/teams_spec.js new file mode 100644 index 0000000000..355de9074a --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/teams_spec.js @@ -0,0 +1,53 @@ +define([ + 'teams/js/collections/team', 'teams/js/views/teams' +], function (TeamCollection, TeamsView) { + 'use strict'; + describe('TeamsView', function () { + var teamsView, teamCollection, initialTeams, + createTeams = function (startIndex, stopIndex) { + return _.map(_.range(startIndex, stopIndex + 1), function (i) { + return { + name: "team " + i, + id: "id " + i, + language: "English", + country: "Sealand", + is_active: true, + membership: [] + }; + }); + }; + + beforeEach(function () { + setFixtures(''); + initialTeams = createTeams(1, 5); + teamCollection = new TeamCollection( + { + count: 6, + num_pages: 2, + current_page: 1, + start: 0, + results: initialTeams + }, + {course_id: 'my/course/id', parse: true} + ); + teamsView = new TeamsView({ + el: '.teams-container', + collection: teamCollection + }).render(); + }); + + it('can render itself', function () { + var footerEl = teamsView.$('.teams-paging-footer'), + teamCards = teamsView.$('.team-card'); + expect(teamsView.$('.teams-paging-header').text()).toMatch('Showing 1-5 out of 6 total'); + _.each(initialTeams, function (team, index) { + var currentCard = teamCards.eq(index); + expect(currentCard.text()).toMatch(team.name); + expect(currentCard.text()).toMatch(team.language); + expect(currentCard.text()).toMatch(team.country); + }); + expect(footerEl.text()).toMatch('1\\s+out of\\s+\/\\s+2'); + expect(footerEl).not.toHaveClass('hidden'); + }); + }); +}); diff --git a/lms/djangoapps/teams/static/teams/js/spec/teams_tab_spec.js b/lms/djangoapps/teams/static/teams/js/spec/teams_tab_spec.js new file mode 100644 index 0000000000..55cf4e7160 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/teams_tab_spec.js @@ -0,0 +1,74 @@ +define([ + 'jquery', + 'backbone', + 'common/js/spec_helpers/ajax_helpers', + 'teams/js/views/teams_tab' +], function ($, Backbone, AjaxHelpers, TeamsTabView) { + 'use strict'; + + describe('TeamsTab', function () { + var teamsTabView, + expectContent = function (text) { + expect(teamsTabView.$('.page-content-main').text()).toContain(text); + }, + expectHeader = function (text) { + expect(teamsTabView.$('.teams-header').text()).toContain(text); + }, + expectError = function (text) { + expect(teamsTabView.$('.warning').text()).toContain(text); + }; + + beforeEach(function () { + setFixtures(''); + teamsTabView = new TeamsTabView({ + el: $('.teams-content'), + topics: { + count: 1, + num_pages: 1, + current_page: 1, + start: 0, + results: [{ + description: 'test description', + name: 'test topic', + id: 'test_id', + team_count: 0 + }] + }, + topic_url: 'api/topics/topic_id,course_id', + topics_url: 'topics_url', + teams_url: 'teams_url', + course_id: 'test/course/id' + }).render(); + Backbone.history.start(); + }); + + afterEach(function () { + Backbone.history.stop(); + }); + + it('shows the teams tab initially', function () { + expectHeader('See all teams in your course, organized by topic'); + expectContent('This is the new Teams tab.'); + }); + + it('can switch tabs', function () { + teamsTabView.$('a.nav-item[data-url="browse"]').click(); + expectContent('test description'); + teamsTabView.$('a.nav-item[data-url="teams"]').click(); + expectContent('This is the new Teams tab.'); + }); + + it('displays an error message when trying to navigate to a nonexistent route', function () { + teamsTabView.router.navigate('test', {trigger: true}); + expectError('The page "test" could not be found.'); + }); + + it('displays an error message when trying to navigate to a nonexistent topic', function () { + var requests = AjaxHelpers.requests(this); + teamsTabView.router.navigate('topics/test', {trigger: true}); + AjaxHelpers.expectRequest(requests, 'GET', 'api/topics/test,course_id', null); + AjaxHelpers.respondWithError(requests, 404); + expectError('The topic "test" could not be found.'); + }); + }); +}); diff --git a/lms/djangoapps/teams/static/teams/js/spec/topics_spec.js b/lms/djangoapps/teams/static/teams/js/spec/topics_spec.js index 34b3017372..3a4bade0aa 100644 --- a/lms/djangoapps/teams/static/teams/js/spec/topics_spec.js +++ b/lms/djangoapps/teams/static/teams/js/spec/topics_spec.js @@ -1,13 +1,10 @@ define([ - 'common/js/spec_helpers/ajax_helpers', 'teams/js/collections/topic', 'teams/js/views/topics' -], function (AjaxHelpers, TopicCollection, TopicsView) { + 'teams/js/collections/topic', 'teams/js/views/topics' +], function (TopicCollection, TopicsView) { 'use strict'; describe('TopicsView', function () { - var initialTopics, topicCollection, topicsView, nextPageButtonCss; - - nextPageButtonCss = '.next-page-link'; - - function generateTopics(startIndex, stopIndex) { + var initialTopics, topicCollection, topicsView, + generateTopics = function (startIndex, stopIndex) { return _.map(_.range(startIndex, stopIndex + 1), function (i) { return { "description": "description " + i, @@ -16,7 +13,7 @@ define([ "team_count": 0 }; }); - } + }; beforeEach(function () { setFixtures(''); @@ -34,143 +31,18 @@ define([ topicsView = new TopicsView({el: '.topics-container', collection: topicCollection}).render(); }); - /** - * Verify that the topics view's header reflects the page we're currently viewing. - * @param matchString the header we expect to see - */ - function expectHeader(matchString) { - expect(topicsView.$('.topics-paging-header').text()).toMatch(matchString); - } - - /** - * Verify that the topics list view renders the expected topics - * @param expectedTopics an array of topic objects we expect to see - */ - function expectTopics(expectedTopics) { - var topicCards; - topicCards = topicsView.$('.topic-card'); - _.each(expectedTopics, function (topic, index) { + it('can render the first of many pages', function () { + var footerEl = topicsView.$('.topics-paging-footer'), + topicCards = topicsView.$('.topic-card'); + expect(topicsView.$('.topics-paging-header').text()).toMatch('Showing 1-5 out of 6 total'); + _.each(initialTopics, function (topic, index) { var currentCard = topicCards.eq(index); expect(currentCard.text()).toMatch(topic.name); expect(currentCard.text()).toMatch(topic.description); expect(currentCard.text()).toMatch(topic.team_count + ' Teams'); }); - } - - /** - * Verify that the topics footer reflects the current pagination - * @param options a parameters hash containing: - * - currentPage: the one-indexed page we expect to be viewing - * - totalPages: the total number of pages to page through - * - isHidden: whether the footer is expected to be visible - */ - function expectFooter(options) { - var footerEl = topicsView.$('.topics-paging-footer'); - expect(footerEl.text()) - .toMatch(new RegExp(options.currentPage + '\\s+out of\\s+\/\\s+' + topicCollection.totalPages)); - expect(footerEl.hasClass('hidden')).toBe(options.isHidden); - } - - it('can render the first of many pages', function () { - expectHeader('Showing 1-5 out of 6 total'); - expectTopics(initialTopics); - expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); - }); - - it('can render the only page', function () { - initialTopics = generateTopics(1, 1); - topicCollection.set( - { - "count": 1, - "num_pages": 1, - "current_page": 1, - "start": 0, - "results": initialTopics - }, - {parse: true} - ); - expectHeader('Showing 1 out of 1 total'); - expectTopics(initialTopics); - expectFooter({currentPage: 1, totalPages: 1, isHidden: true}); - }); - - it('can change to the next page', function () { - var requests = AjaxHelpers.requests(this), - newTopics = generateTopics(1, 1); - expectHeader('Showing 1-5 out of 6 total'); - expectTopics(initialTopics); - expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); - expect(requests.length).toBe(0); - topicsView.$(nextPageButtonCss).click(); - expect(requests.length).toBe(1); - AjaxHelpers.respondWithJson(requests, { - "count": 6, - "num_pages": 2, - "current_page": 2, - "start": 5, - "results": newTopics - }); - expectHeader('Showing 6-6 out of 6 total'); - expectTopics(newTopics); - expectFooter({currentPage: 2, totalPages: 2, isHidden: false}); - }); - - it('can change to the previous page', function () { - var requests = AjaxHelpers.requests(this), - previousPageTopics; - initialTopics = generateTopics(1, 1); - topicCollection.set( - { - "count": 6, - "num_pages": 2, - "current_page": 2, - "start": 5, - "results": initialTopics - }, - {parse: true} - ); - expectHeader('Showing 6-6 out of 6 total'); - expectTopics(initialTopics); - expectFooter({currentPage: 2, totalPages: 2, isHidden: false}); - topicsView.$('.previous-page-link').click(); - previousPageTopics = generateTopics(1, 5); - AjaxHelpers.respondWithJson(requests, { - "count": 6, - "num_pages": 2, - "current_page": 1, - "start": 0, - "results": previousPageTopics - }); - expectHeader('Showing 1-5 out of 6 total'); - expectTopics(previousPageTopics); - expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); - }); - - it('sets focus for screen readers', function () { - var requests = AjaxHelpers.requests(this); - spyOn($.fn, 'focus'); - topicsView.$(nextPageButtonCss).click(); - AjaxHelpers.respondWithJson(requests, { - "count": 6, - "num_pages": 2, - "current_page": 2, - "start": 5, - "results": generateTopics(1, 1) - }); - expect(topicsView.$('.sr-is-focusable').focus).toHaveBeenCalled(); - }); - - it('does not change on server error', function () { - var requests = AjaxHelpers.requests(this), - expectInitialState = function () { - expectHeader('Showing 1-5 out of 6 total'); - expectTopics(initialTopics); - expectFooter({currentPage: 1, totalPages: 2, isHidden: false}); - }; - expectInitialState(); - topicsView.$(nextPageButtonCss).click(); - requests[0].respond(500); - expectInitialState(); + expect(footerEl.text()).toMatch('1\\s+out of\\s+\/\\s+2'); + expect(footerEl).not.toHaveClass('hidden'); }); }); }); diff --git a/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js b/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js index 8a0a6cc64a..4d66791542 100644 --- a/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js +++ b/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js @@ -1,16 +1,12 @@ ;(function (define) { 'use strict'; - define(['jquery', 'teams/js/views/teams_tab', 'teams/js/collections/topic'], - function ($, TeamsTabView, TopicCollection) { - return function (element, topics, topics_url, course_id) { - var topicCollection = new TopicCollection(topics, {url: topics_url, course_id: course_id, parse: true}); - topicCollection.bootstrap(); - var view = new TeamsTabView({ - el: element, - topicCollection: topicCollection - }); - view.render(); + define(['jquery', 'teams/js/views/teams_tab'], + function ($, TeamsTabView) { + return function (options) { + var teamsTab = new TeamsTabView(_.extend(options, {el: $('.teams-content')})); + teamsTab.render(); + Backbone.history.start(); }; }); }).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/teams/static/teams/js/views/team_card.js b/lms/djangoapps/teams/static/teams/js/views/team_card.js new file mode 100644 index 0000000000..cdfbeda5cf --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/views/team_card.js @@ -0,0 +1,85 @@ +;(function (define) { + 'use strict'; + define([ + 'backbone', + 'underscore', + 'gettext', + 'js/components/card/views/card', + 'text!teams/templates/team-country-language.underscore' + ], function (Backbone, _, gettext, CardView, teamCountryLanguageTemplate) { + var TeamMembershipView, TeamCountryLanguageView, TeamCardView; + + TeamMembershipView = Backbone.View.extend({ + tagName: 'div', + className: 'team-members', + template: _.template( + '<%= membership_message %>' + + '' + message + '
' + country + ''); } %> +<% if (language) { print(''); } %> diff --git a/lms/djangoapps/teams/static/teams/templates/teams.underscore b/lms/djangoapps/teams/static/teams/templates/teams.underscore new file mode 100644 index 0000000000..e0a7a180c2 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/templates/teams.underscore @@ -0,0 +1,4 @@ + + + + diff --git a/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore b/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore new file mode 100644 index 0000000000..239eb2b443 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore @@ -0,0 +1,12 @@ +<%- description %>
+