Add teams-for-topic list view to Teams page.

TNL-1898

Also adds a generic paginated list view and Teams list view. The
common PaginatedView takes a collection and a view class for its items
and creates a header and footer with correct pagination. The topics
list view is refactored to use this generic view.

Authors:
  - Peter Fogg <pfogg@edx.org>
  - Daniel Friedman <dfriedman58@gmail.com>
This commit is contained in:
Daniel Friedman
2015-07-08 17:41:08 -04:00
parent 6d6bfa7f35
commit 21b39ca467
38 changed files with 1449 additions and 314 deletions

View File

@@ -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);

View File

@@ -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');

View File

@@ -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);

View File

@@ -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('<div class="test-container"></div>');
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();
});
});
});

View File

@@ -0,0 +1,4 @@
<div class="sr-is-focusable sr-<%= type %>-view" tabindex="-1"></div>
<div class="<%= type %>-paging-header"></div>
<div class="<%= type %>-list"></div>
<div class="<%= type %>-paging-footer"></div>

View File

@@ -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'

View File

@@ -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')

View File

@@ -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'
)