Commit d61d193d by Daniel Friedman

Merge pull request #8859 from edx/dan-f/teams-list-view

Teams List View
parents 6ee90509 21b39ca4
;(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);
...@@ -23,7 +23,8 @@ ...@@ -23,7 +23,8 @@
var onFirstPage = !this.collection.hasPreviousPage(), var onFirstPage = !this.collection.hasPreviousPage(),
onLastPage = !this.collection.hasNextPage(); onLastPage = !this.collection.hasNextPage();
if (this.hideWhenOnePage) { if (this.hideWhenOnePage) {
if (this.collection.totalPages <= 1) { if (_.isUndefined(this.collection.totalPages)
|| this.collection.totalPages <= 1) {
this.$el.addClass('hidden'); this.$el.addClass('hidden');
} else if (this.$el.hasClass('hidden')) { } else if (this.$el.hasClass('hidden')) {
this.$el.removeClass('hidden'); this.$el.removeClass('hidden');
......
...@@ -16,9 +16,9 @@ ...@@ -16,9 +16,9 @@
render: function () { render: function () {
var message, var message,
start = this.collection.start, start = _.isUndefined(this.collection.start) ? 0 : this.collection.start,
end = start + this.collection.length, 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}; context = {first_index: Math.min(start + 1, end), last_index: end, num_items: num_items};
if (end <= 1) { if (end <= 1) {
message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true); message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true);
......
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();
});
});
});
<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>
...@@ -156,6 +156,7 @@ ...@@ -156,6 +156,7 @@
define([ define([
// Run the common tests that use RequireJS. // Run the common tests that use RequireJS.
'common-requirejs/include/common/js/spec/components/list_spec.js', '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_collection_spec.js',
'common-requirejs/include/common/js/spec/components/paging_header_spec.js', 'common-requirejs/include/common/js/spec/components/paging_header_spec.js',
'common-requirejs/include/common/js/spec/components/paging_footer_spec.js' 'common-requirejs/include/common/js/spec/components/paging_footer_spec.js'
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Teams page. Teams pages.
""" """
from .course_page import CoursePage from .course_page import CoursePage
...@@ -9,6 +9,8 @@ from ..common.paging import PaginatedUIMixin ...@@ -9,6 +9,8 @@ from ..common.paging import PaginatedUIMixin
TOPIC_CARD_CSS = 'div.wrapper-card-core' TOPIC_CARD_CSS = 'div.wrapper-card-core'
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]' BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]'
TEAMS_LINK_CSS = '.action-view'
TEAMS_HEADER_CSS = '.teams-header'
class TeamsPage(CoursePage): class TeamsPage(CoursePage):
...@@ -53,3 +55,50 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -53,3 +55,50 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
def topic_cards(self): def topic_cards(self):
"""Return a list of the topic cards present on the page.""" """Return a list of the topic cards present on the page."""
return self.q(css=TOPIC_CARD_CSS).results 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')
""" """
Acceptance tests for the teams feature. Acceptance tests for the teams feature.
""" """
from ..helpers import UniqueCourseTest import json
from ...pages.lms.teams import TeamsPage, BrowseTopicsPage
from nose.plugins.attrib import attr 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 ...fixtures.course import CourseFixture
from ...pages.lms.tab_nav import TabNavPage from ...pages.lms.tab_nav import TabNavPage
from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.course_info import CourseInfoPage from ...pages.lms.course_info import CourseInfoPage
@attr('shard_5') class TeamsTabBase(UniqueCourseTest):
class TeamsTabTest(UniqueCourseTest): """Base class for Teams Tab tests"""
"""
Tests verifying when the Teams tab is present.
"""
def setUp(self): def setUp(self):
super(TeamsTabTest, self).setUp() super(TeamsTabBase, self).setUp()
self.tab_nav = TabNavPage(self.browser) self.tab_nav = TabNavPage(self.browser)
self.course_info_page = CourseInfoPage(self.browser, self.course_id) self.course_info_page = CourseInfoPage(self.browser, self.course_id)
self.teams_page = TeamsPage(self.browser, self.course_id) self.teams_page = TeamsPage(self.browser, self.course_id)
...@@ -39,7 +39,8 @@ class TeamsTabTest(UniqueCourseTest): ...@@ -39,7 +39,8 @@ class TeamsTabTest(UniqueCourseTest):
self.course_fixture.install() self.course_fixture.install()
enroll_course_id = self.course_id if enroll_in_course else None 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() self.course_info_page.visit()
def verify_teams_present(self, present): def verify_teams_present(self, present):
...@@ -54,6 +55,12 @@ class TeamsTabTest(UniqueCourseTest): ...@@ -54,6 +55,12 @@ class TeamsTabTest(UniqueCourseTest):
else: else:
self.assertNotIn("Teams", self.tab_nav.tab_names) 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): def test_teams_not_enabled(self):
""" """
Scenario: teams tab should not be present if no team configuration is set Scenario: teams tab should not be present if no team configuration is set
...@@ -118,7 +125,7 @@ class TeamsTabTest(UniqueCourseTest): ...@@ -118,7 +125,7 @@ class TeamsTabTest(UniqueCourseTest):
@attr('shard_5') @attr('shard_5')
class BrowseTopicsTest(TeamsTabTest): class BrowseTopicsTest(TeamsTabBase):
""" """
Tests for the Browse tab of the Teams page. Tests for the Browse tab of the Teams page.
""" """
...@@ -228,3 +235,223 @@ class BrowseTopicsTest(TeamsTabTest): ...@@ -228,3 +235,223 @@ class BrowseTopicsTest(TeamsTabTest):
self.assertLess(len(truncated_description), len(initial_description)) self.assertLess(len(truncated_description), len(initial_description))
self.assertTrue(truncated_description.endswith('...')) self.assertTrue(truncated_description.endswith('...'))
self.assertIn(truncated_description.split('...')[0], initial_description) 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'
)
...@@ -19,7 +19,7 @@ TOPIC_ID_PATTERN = TEAM_ID_PATTERN.replace('team_id', 'topic_id') ...@@ -19,7 +19,7 @@ TOPIC_ID_PATTERN = TEAM_ID_PATTERN.replace('team_id', 'topic_id')
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url( url(
r'^v0/teams$', r'^v0/teams/$',
TeamsListView.as_view(), TeamsListView.as_view(),
name="teams_list" name="teams_list"
), ),
...@@ -39,7 +39,7 @@ urlpatterns = patterns( ...@@ -39,7 +39,7 @@ urlpatterns = patterns(
name="topics_detail" name="topics_detail"
), ),
url( url(
r'^v0/team_membership$', r'^v0/team_membership/$',
MembershipListView.as_view(), MembershipListView.as_view(),
name="team_membership_list" name="team_membership_list"
), ),
......
"""Defines serializers used by the Team API.""" """Defines serializers used by the Team API."""
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Count
from rest_framework import serializers 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 openedx.core.lib.api.fields import ExpandableField
from .models import CourseTeam, CourseTeamMembership
from openedx.core.djangoapps.user_api.serializers import UserSerializer from openedx.core.djangoapps.user_api.serializers import UserSerializer
from .models import CourseTeam, CourseTeamMembership
class UserMembershipSerializer(serializers.ModelSerializer): class UserMembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with only user and date_joined """Serializes CourseTeamMemberships with only user and date_joined
...@@ -108,8 +112,43 @@ class MembershipSerializer(serializers.ModelSerializer): ...@@ -108,8 +112,43 @@ class MembershipSerializer(serializers.ModelSerializer):
read_only_fields = ("date_joined",) read_only_fields = ("date_joined",)
class TopicSerializer(serializers.Serializer): class BaseTopicSerializer(serializers.Serializer):
"""Serializes a topic.""" """Serializes a topic without team_count."""
description = serializers.CharField() description = serializers.CharField()
name = serializers.CharField() name = serializers.CharField()
id = serializers.CharField() # pylint: disable=invalid-name 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)
;(function (define) {
'use strict';
define(['common/js/components/collections/paging_collection', 'teams/js/models/team', 'gettext'],
function(PagingCollection, TeamModel, gettext) {
var TeamCollection = PagingCollection.extend({
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 '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'));
this.registerSortableField('open_slots', gettext('open_slots'));
},
model: TeamModel
});
return TeamCollection;
});
}).call(this, define || RequireJS.define);
/**
* Model for a team.
*/
(function (define) {
'use strict';
define(['backbone'], function (Backbone) {
var Team = Backbone.Model.extend({
defaults: {
id: '',
name: '',
is_active: null,
course_id: '',
topic_id: '',
date_created: '',
description: '',
country: '',
language: '',
membership: []
}
});
return Team;
});
}).call(this, define || RequireJS.define);
...@@ -7,7 +7,13 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"], ...@@ -7,7 +7,13 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"],
beforeEach(function() { beforeEach(function() {
setFixtures('<section class="teams-content"></section>'); setFixtures('<section class="teams-content"></section>');
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() { afterEach(function() {
...@@ -19,7 +25,7 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"], ...@@ -19,7 +25,7 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"],
}); });
it("displays a header", function() { 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");
}); });
}); });
} }
......
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('<div class="teams-container"></div>');
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');
});
});
});
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('<div class="teams-content"></div>');
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.');
});
});
});
define([ define([
'common/js/spec_helpers/ajax_helpers', 'teams/js/collections/topic', 'teams/js/views/topics' 'teams/js/collections/topic', 'teams/js/views/topics'
], function (AjaxHelpers, TopicCollection, TopicsView) { ], function (TopicCollection, TopicsView) {
'use strict'; 'use strict';
describe('TopicsView', function () { describe('TopicsView', function () {
var initialTopics, topicCollection, topicsView, nextPageButtonCss; var initialTopics, topicCollection, topicsView,
generateTopics = function (startIndex, stopIndex) {
nextPageButtonCss = '.next-page-link';
function generateTopics(startIndex, stopIndex) {
return _.map(_.range(startIndex, stopIndex + 1), function (i) { return _.map(_.range(startIndex, stopIndex + 1), function (i) {
return { return {
"description": "description " + i, "description": "description " + i,
...@@ -16,7 +13,7 @@ define([ ...@@ -16,7 +13,7 @@ define([
"team_count": 0 "team_count": 0
}; };
}); });
} };
beforeEach(function () { beforeEach(function () {
setFixtures('<div class="topics-container"></div>'); setFixtures('<div class="topics-container"></div>');
...@@ -34,143 +31,18 @@ define([ ...@@ -34,143 +31,18 @@ define([
topicsView = new TopicsView({el: '.topics-container', collection: topicCollection}).render(); topicsView = new TopicsView({el: '.topics-container', collection: topicCollection}).render();
}); });
/** it('can render the first of many pages', function () {
* Verify that the topics view's header reflects the page we're currently viewing. var footerEl = topicsView.$('.topics-paging-footer'),
* @param matchString the header we expect to see topicCards = topicsView.$('.topic-card');
*/ expect(topicsView.$('.topics-paging-header').text()).toMatch('Showing 1-5 out of 6 total');
function expectHeader(matchString) { _.each(initialTopics, function (topic, index) {
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) {
var currentCard = topicCards.eq(index); var currentCard = topicCards.eq(index);
expect(currentCard.text()).toMatch(topic.name); expect(currentCard.text()).toMatch(topic.name);
expect(currentCard.text()).toMatch(topic.description); expect(currentCard.text()).toMatch(topic.description);
expect(currentCard.text()).toMatch(topic.team_count + ' Teams'); expect(currentCard.text()).toMatch(topic.team_count + ' Teams');
}); });
} expect(footerEl.text()).toMatch('1\\s+out of\\s+\/\\s+2');
expect(footerEl).not.toHaveClass('hidden');
/**
* 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();
}); });
}); });
}); });
;(function (define) { ;(function (define) {
'use strict'; 'use strict';
define(['jquery', 'teams/js/views/teams_tab', 'teams/js/collections/topic'], define(['jquery', 'teams/js/views/teams_tab'],
function ($, TeamsTabView, TopicCollection) { function ($, TeamsTabView) {
return function (element, topics, topics_url, course_id) { return function (options) {
var topicCollection = new TopicCollection(topics, {url: topics_url, course_id: course_id, parse: true}); var teamsTab = new TeamsTabView(_.extend(options, {el: $('.teams-content')}));
topicCollection.bootstrap(); teamsTab.render();
var view = new TeamsTabView({ Backbone.history.start();
el: element,
topicCollection: topicCollection
});
view.render();
}; };
}); });
}).call(this, define || RequireJS.define); }).call(this, define || RequireJS.define);
;(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(
'<span class="member-count"><%= membership_message %></span>' +
'<ul class="list-member-thumbs"></ul>'
),
initialize: function (options) {
this.maxTeamSize = options.maxTeamSize;
},
render: function () {
var memberships = this.model.get('membership'),
maxMemberCount = this.maxTeamSize;
this.$el.html(this.template({
membership_message: interpolate(
// Translators: The following message displays the number of members on a team.
ngettext(
'%(member_count)s / %(max_member_count)s Member',
'%(member_count)s / %(max_member_count)s Members',
maxMemberCount
),
{member_count: memberships.length, max_member_count: maxMemberCount}, true
)
}));
_.each(memberships, function (membership) {
this.$('list-member-thumbs').append(
'<li class="item-member-thumb"><img alt="' + membership.user.username + '" src=""></img></li>'
);
}, this);
return this;
}
});
TeamCountryLanguageView = Backbone.View.extend({
template: _.template(teamCountryLanguageTemplate),
render: function() {
// this.$el should be the card meta div
this.$el.append(this.template({
country: this.model.get('country'),
language: this.model.get('language')
}));
}
});
TeamCardView = CardView.extend({
initialize: function () {
CardView.prototype.initialize.apply(this, arguments);
// TODO: show last activity detail view
this.detailViews = [
new TeamMembershipView({model: this.model, maxTeamSize: this.maxTeamSize}),
new TeamCountryLanguageView({model: this.model})
];
},
configuration: 'list_card',
cardClass: 'team-card',
title: function () { return this.model.get('name'); },
description: function () { return this.model.get('description'); },
details: function () { return this.detailViews; },
actionClass: 'action-view',
actionContent: function() {
return interpolate(
gettext('View %(span_start)s %(team_name)s %(span_end)s'),
{span_start: '<span class="sr">', team_name: this.model.get('name'), span_end: '</span>'},
true
);
}
});
return TeamCardView;
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define([
'teams/js/views/team_card',
'common/js/components/views/paginated_view'
], function (TeamCardView, PaginatedView) {
var TeamsView = PaginatedView.extend({
type: 'teams',
events: {
'click button.action': '' // entry point for team creation
},
initialize: function (options) {
this.itemViewClass = TeamCardView.extend({
router: options.router,
maxTeamSize: options.maxTeamSize
});
PaginatedView.prototype.initialize.call(this);
},
render: function () {
PaginatedView.prototype.render.call(this);
this.$el.append(
$('<button class="action action-primary">' + gettext('Create new team') + '</button>')
);
return this;
}
});
return TeamsView;
});
}).call(this, define || RequireJS.define);
...@@ -7,19 +7,52 @@ ...@@ -7,19 +7,52 @@
'js/components/header/views/header', 'js/components/header/views/header',
'js/components/header/models/header', 'js/components/header/models/header',
'js/components/tabbed/views/tabbed_view', 'js/components/tabbed/views/tabbed_view',
'teams/js/views/topics'], 'teams/js/views/topics',
function (Backbone, _, gettext, HeaderView, HeaderModel, TabbedView, TopicsView) { 'teams/js/models/topic',
'teams/js/collections/topic',
'teams/js/views/teams',
'teams/js/collections/team',
'text!teams/templates/teams_tab.underscore'],
function (Backbone, _, gettext, HeaderView, HeaderModel, TabbedView,
TopicsView, TopicModel, TopicCollection, TeamsView, TeamCollection, teamsTemplate) {
var ViewWithHeader = Backbone.View.extend({
initialize: function (options) {
this.header = options.header;
this.main = options.main;
},
render: function () {
this.$el.html(_.template(teamsTemplate));
this.$('p.error').hide();
this.header.setElement(this.$('.teams-header')).render();
this.main.setElement(this.$('.page-content')).render();
return this;
}
});
var TeamTabView = Backbone.View.extend({ var TeamTabView = Backbone.View.extend({
initialize: function(options) { initialize: function(options) {
this.headerModel = new HeaderModel({ var TempTabView, router;
description: gettext("Course teams are organized into topics created by course instructors. Try to join others in an existing team before you decide to create a new team!"), this.course_id = options.course_id;
title: gettext("Teams") this.topics = options.topics;
}); this.topic_url = options.topic_url;
this.headerView = new HeaderView({ this.teams_url = options.teams_url;
model: this.headerModel this.maxTeamSize = options.maxTeamSize;
// This slightly tedious approach is necessary
// to use regular expressions within Backbone
// routes, allowing us to capture which tab
// name is being routed to
router = this.router = new Backbone.Router();
_.each([
[':default', _.bind(this.routeNotFound, this)],
['topics/:topic_id', _.bind(this.browseTopic, this)],
[new RegExp('^(browse)$'), _.bind(this.goToTab, this)],
[new RegExp('^(teams)$'), _.bind(this.goToTab, this)]
], function (route) {
router.route.apply(router, route);
}); });
// TODO replace this with actual views! // TODO replace this with actual views!
var TempTabView = Backbone.View.extend({ TempTabView = Backbone.View.extend({
initialize: function (options) { initialize: function (options) {
this.text = options.text; this.text = options.text;
}, },
...@@ -28,27 +61,204 @@ ...@@ -28,27 +61,204 @@
this.$el.text(this.text); this.$el.text(this.text);
} }
}); });
this.tabbedView = new TabbedView({ this.topicsCollection = new TopicCollection(
tabs: [{ this.topics,
title: gettext('My Teams'), {url: options.topics_url, course_id: this.course_id, parse: true}
url: 'teams', ).bootstrap();
view: new TempTabView({text: 'This is the new Teams tab.'}) this.mainView = this.tabbedView = new ViewWithHeader({
}, { header: new HeaderView({
title: gettext('Browse'), model: new HeaderModel({
url: 'browse', description: gettext("See all teams in your course, organized by topic. Join a team to collaborate with other learners who are interested in the same topic as you are."),
view: new TopicsView({ title: gettext("Teams")
collection: options.topicCollection
}) })
}] }),
main: new TabbedView({
tabs: [{
title: gettext('My Teams'),
url: 'teams',
view: new TempTabView({text: 'This is the new Teams tab.'})
}, {
title: gettext('Browse'),
url: 'browse',
view: new TopicsView({
collection: this.topicsCollection,
router: this.router
})
}],
router: this.router
})
}); });
Backbone.history.start();
}, },
render: function() { render: function() {
this.$el.append(this.headerView.$el); this.mainView.setElement(this.$el).render();
this.headerView.render(); this.hideWarning();
this.$el.append(this.tabbedView.$el); return this;
this.tabbedView.render(); },
/**
* Render the list of teams for the given topic ID.
*/
browseTopic: function (topicID) {
var self = this;
this.getTeamsView(topicID).done(function (teamsView) {
self.mainView = teamsView;
self.render();
});
},
/**
* Return a promise for the TeamsView for the given
* topic ID.
*/
getTeamsView: function (topicID) {
// Lazily load the teams-for-topic view in
// order to avoid making an extra AJAX call.
if (!_.isUndefined(this.teamsView)
&& this.teamsView.main.collection.topic_id === topicID) {
return this.identityPromise(this.teamsView);
}
var self = this,
teamCollection = new TeamCollection([], {
course_id: this.course_id,
url: this.teams_url,
topic_id: topicID,
per_page: 10
}),
teamPromise = teamCollection.goTo(1).fail(function (xhr) {
if (xhr.status === 400) {
self.topicNotFound(topicID);
}
}),
topicPromise = this.getTopic(topicID).fail(function (xhr) {
if (xhr.status === 404) {
self.topicNotFound(topicID);
}
});
return $.when(topicPromise, teamPromise).pipe(
_.bind(this.constructTeamView, this)
);
},
/**
* Given a topic and the results of the team
* collection's fetch(), return the team list view.
*/
constructTeamView: function (topic, collectionResults) {
var self = this,
headerView = new HeaderView({
model: new HeaderModel({
description: _.escape(topic.get('description')),
title: _.escape(topic.get('name')),
breadcrumbs: [{
title: 'All topics',
url: '#'
}]
}),
events: {
'click nav.breadcrumbs a.nav-item': function (event) {
event.preventDefault();
self.router.navigate('browse', {trigger: true});
}
}
});
return new ViewWithHeader({
header: headerView,
main: new TeamsView({
collection: new TeamCollection(collectionResults[0], {
course_id: this.course_id,
url: this.teams_url,
topic_id: topic.get('id'),
per_page: 10,
parse: true
}),
maxTeamSize: this.maxTeamSize
})
});
},
/**
* Get a topic given a topic ID. Returns a jQuery deferred
* promise, since the topic may need to be fetched from the
* server.
* @param topicID the string identifier for the requested topic
* @returns a jQuery deferred promise for the topic.
*/
getTopic: function (topicID) {
// Try finding topic in the current page of the
// topicCollection. Otherwise call the topic endpoint.
var topic = this.topicsCollection.findWhere({'id': topicID}),
self = this;
if (topic) {
return this.identityPromise(topic);
} else {
var TopicModelWithUrl = TopicModel.extend({
url: function () { return self.topic_url.replace('topic_id', this.id); }
});
return (new TopicModelWithUrl({id: topicID })).fetch();
}
},
/**
* Immediately return a promise for the given
* object.
*/
identityPromise: function (obj) {
return new $.Deferred().resolve(obj).promise();
},
/**
* Set up the tabbed view and switch tabs.
*/
goToTab: function (tab) {
this.mainView = this.tabbedView;
// Note that `render` should be called first so
// that the tabbed view's element is set
// correctly.
this.render();
this.tabbedView.main.setActiveTab(tab);
},
// Error handling
routeNotFound: function (route) {
this.notFoundError(
interpolate(
gettext('The page "%(route)s" could not be found.'),
{route: route},
true
)
);
},
topicNotFound: function (topicID) {
this.notFoundError(
interpolate(
gettext('The topic "%(topic)s" could not be found.'),
{topic: topicID},
true
)
);
},
/**
* Called when the user attempts to navigate to a
* route that doesn't exist. "Redirects" back to
* the main teams tab, and adds an error message.
*/
notFoundError: function (message) {
this.router.navigate('teams', {trigger: true});
this.showWarning(message);
},
showWarning: function (message) {
var warningEl = this.$('.warning');
warningEl.find('.copy').html('<p>' + message + '</p');
warningEl.toggleClass('is-hidden', false);
},
hideWarning: function () {
this.$('.warning').toggleClass('is-hidden', true);
} }
}); });
......
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
action: function (event) { action: function (event) {
event.preventDefault(); event.preventDefault();
// TODO implement actual navigation this.router.navigate('topics/' + this.model.get('id'), {trigger: true});
}, },
configuration: 'square_card', configuration: 'square_card',
......
;(function (define) { ;(function (define) {
'use strict'; 'use strict';
define([ define([
'backbone',
'underscore',
'gettext',
'common/js/components/views/list',
'common/js/components/views/paging_header',
'common/js/components/views/paging_footer',
'teams/js/views/topic_card', 'teams/js/views/topic_card',
'text!teams/templates/topics.underscore' 'common/js/components/views/paginated_view'
], function (Backbone, _, gettext, ListView, PagingHeader, PagingFooterView, TopicCardView, topics_template) { ], function (TopicCardView, PaginatedView) {
var TopicsListView = ListView.extend({ var TopicsView = PaginatedView.extend({
tagName: 'div', type: 'topics',
className: 'topics-container',
itemViewClass: TopicCardView
});
var TopicsView = Backbone.View.extend({
initialize: function() {
this.listView = new TopicsListView({collection: this.collection});
this.headerView = new PagingHeader({collection: this.collection});
this.pagingFooterView = new PagingFooterView({
collection: this.collection, hideWhenOnePage: true
});
// Focus top of view for screen readers
this.collection.on('page_changed', function () {
this.$('.sr-is-focusable.sr-topics-view').focus();
}, this);
},
render: function() {
this.$el.html(_.template(topics_template));
this.assign(this.listView, '.topics-list');
this.assign(this.headerView, '.topics-paging-header');
this.assign(this.pagingFooterView, '.topics-paging-footer');
return this;
},
/** initialize: function (options) {
* Helper method to render subviews and re-bind events. this.itemViewClass = TopicCardView.extend({router: options.router});
* PaginatedView.prototype.initialize.call(this);
* Borrowed from http://ianstormtaylor.com/rendering-views-in-backbonejs-isnt-always-simple/
*
* @param view The Backbone view to render
* @param selector The string CSS selector which the view should attach to
*/
assign: function(view, selector) {
view.setElement(this.$(selector)).render();
} }
}); });
return TopicsView; return TopicsView;
......
<% if (country) { print('<p class="meta-detail team-location"><span class="icon fa-globe"></span>' + country + '</p>'); } %>
<% if (language) { print('<p class="meta-detail team-language"><span class="icon fa-chat"></span>' + language + '</p>'); } %>
<div class="sr-is-focusable sr-teams-view" tabindex="-1"></div>
<div class="teams-paging-header"></div>
<div class="teams-list"></div>
<div class="teams-paging-footer"></div>
<div class="wrapper-msg is-incontext urgency-low warning is-hidden">
<div class="msg">
<div class="msg-content">
<div class="copy">
</div>
</div>
</div>
</div>
<div class="teams-header"></div>
<div class="teams-main">
<div class="page-content"></div>
</div>
...@@ -22,6 +22,13 @@ ...@@ -22,6 +22,13 @@
<%block name="js_extra"> <%block name="js_extra">
<%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory"> <%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory">
TeamsTabFactory($('.teams-content'), ${ json.dumps(topics, cls=EscapedEdxJSONEncoder) }, '${ topics_url }', '${ unicode(course.id) }'); new TeamsTabFactory({
topics: ${ json.dumps(topics, cls=EscapedEdxJSONEncoder) },
topic_url: '${ topic_url }',
topics_url: '${ topics_url }',
teams_url: '${ teams_url }',
maxTeamSize: ${ course.teams_max_size },
course_id: '${ unicode(course.id) }'
});
</%static:require_module> </%static:require_module>
</%block> </%block>
# -*- coding: utf-8 -*-
"""
Tests for custom Teams Serializers.
"""
from django.core.paginator import Paginator
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from lms.djangoapps.teams.tests.factories import CourseTeamFactory
from lms.djangoapps.teams.serializers import BaseTopicSerializer, PaginatedTopicSerializer, TopicSerializer
class TopicTestCase(ModuleStoreTestCase):
"""
Base test class to set up a course with topics
"""
def setUp(self):
"""
Set up a course with a teams configuration.
"""
super(TopicTestCase, self).setUp()
self.course = CourseFactory.create(
teams_configuration={
"max_team_size": 10,
"topics": [{u'name': u'Tøpic', u'description': u'The bést topic!', u'id': u'0'}]
}
)
class BaseTopicSerializerTestCase(TopicTestCase):
"""
Tests for the `BaseTopicSerializer`, which should not serialize team count
data.
"""
def test_team_count_not_included(self):
"""Verifies that the `BaseTopicSerializer` does not include team count"""
with self.assertNumQueries(0):
serializer = BaseTopicSerializer(self.course.teams_topics[0])
self.assertEqual(
serializer.data,
{u'name': u'Tøpic', u'description': u'The bést topic!', u'id': u'0'}
)
class TopicSerializerTestCase(TopicTestCase):
"""
Tests for the `TopicSerializer`, which should serialize team count data for
a single topic.
"""
def test_topic_with_no_team_count(self):
"""
Verifies that the `TopicSerializer` correctly displays a topic with a
team count of 0, and that it only takes one SQL query.
"""
with self.assertNumQueries(1):
serializer = TopicSerializer(self.course.teams_topics[0])
self.assertEqual(
serializer.data,
{u'name': u'Tøpic', u'description': u'The bést topic!', u'id': u'0', u'team_count': 0}
)
def test_topic_with_team_count(self):
"""
Verifies that the `TopicSerializer` correctly displays a topic with a
positive team count, and that it only takes one SQL query.
"""
CourseTeamFactory.create(course_id=self.course.id, topic_id=self.course.teams_topics[0]['id'])
with self.assertNumQueries(1):
serializer = TopicSerializer(self.course.teams_topics[0])
self.assertEqual(
serializer.data,
{u'name': u'Tøpic', u'description': u'The bést topic!', u'id': u'0', u'team_count': 1}
)
class PaginatedTopicSerializerTestCase(TopicTestCase):
"""
Tests for the `PaginatedTopicSerializer`, which should serialize team count
data for many topics with constant time SQL queries.
"""
PAGE_SIZE = 5
def _merge_dicts(self, first, second):
"""Convenience method to merge two dicts in a single expression"""
result = first.copy()
result.update(second)
return result
def setup_topics(self, num_topics=5, teams_per_topic=0):
"""
Helper method to set up topics on the course. Returns a list of
created topics.
"""
self.course.teams_configuration['topics'] = []
topics = [
{u'name': u'Tøpic {}'.format(i), u'description': u'The bést topic! {}'.format(i), u'id': unicode(i)}
for i in xrange(num_topics)
]
for i in xrange(num_topics):
topic_id = unicode(i)
self.course.teams_configuration['topics'].append(topics[i])
for _ in xrange(teams_per_topic):
CourseTeamFactory.create(course_id=self.course.id, topic_id=topic_id)
return topics
def assert_serializer_output(self, topics, num_teams_per_topic, num_queries):
"""
Verify that the serializer produced the expected topics.
"""
with self.assertNumQueries(num_queries):
page = Paginator(self.course.teams_topics, self.PAGE_SIZE).page(1)
serializer = PaginatedTopicSerializer(instance=page)
self.assertEqual(
serializer.data['results'],
[self._merge_dicts(topic, {u'team_count': num_teams_per_topic}) for topic in topics]
)
def test_no_topics(self):
"""
Verify that we return no results and make no SQL queries for a page
with no topics.
"""
self.course.teams_configuration['topics'] = []
self.assert_serializer_output([], num_teams_per_topic=0, num_queries=0)
def test_topics_with_no_team_counts(self):
"""
Verify that we serialize topics with no team count, making only one SQL
query.
"""
topics = self.setup_topics(teams_per_topic=0)
self.assert_serializer_output(topics, num_teams_per_topic=0, num_queries=1)
def test_topics_with_team_counts(self):
"""
Verify that we serialize topics with a positive team count, making only
one SQL query.
"""
teams_per_topic = 10
topics = self.setup_topics(teams_per_topic=teams_per_topic)
self.assert_serializer_output(topics, num_teams_per_topic=teams_per_topic, num_queries=1)
def test_subset_of_topics(self):
"""
Verify that we serialize a subset of the course's topics, making only
one SQL query.
"""
teams_per_topic = 10
topics = self.setup_topics(num_topics=self.PAGE_SIZE + 1, teams_per_topic=teams_per_topic)
self.assert_serializer_output(topics[:self.PAGE_SIZE], num_teams_per_topic=teams_per_topic, num_queries=1)
...@@ -6,6 +6,7 @@ import json ...@@ -6,6 +6,7 @@ import json
import ddt import ddt
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from rest_framework.test import APITestCase, APIClient from rest_framework.test import APITestCase, APIClient
...@@ -35,13 +36,16 @@ class TestDashboard(ModuleStoreTestCase): ...@@ -35,13 +36,16 @@ class TestDashboard(ModuleStoreTestCase):
self.teams_url = reverse('teams_dashboard', args=[self.course.id]) self.teams_url = reverse('teams_dashboard', args=[self.course.id])
def test_anonymous(self): def test_anonymous(self):
""" Verifies that an anonymous client cannot access the team dashboard. """ """Verifies that an anonymous client cannot access the team
dashboard, and is redirected to the login page."""
anonymous_client = APIClient() anonymous_client = APIClient()
response = anonymous_client.get(self.teams_url) response = anonymous_client.get(self.teams_url)
self.assertEqual(404, response.status_code) redirect_url = '{0}?next={1}'.format(settings.LOGIN_URL, self.teams_url)
self.assertRedirects(response, redirect_url)
def test_not_enrolled_not_staff(self): def test_not_enrolled_not_staff(self):
""" Verifies that a student who is not enrolled cannot access the team dashboard. """ """ Verifies that a student who is not enrolled cannot access the team dashboard. """
self.client.login(username=self.user.username, password=self.test_password)
response = self.client.get(self.teams_url) response = self.client.get(self.teams_url)
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
...@@ -82,6 +86,8 @@ class TestDashboard(ModuleStoreTestCase): ...@@ -82,6 +86,8 @@ class TestDashboard(ModuleStoreTestCase):
""" """
bad_org = "badorgxxx" bad_org = "badorgxxx"
bad_team_url = self.teams_url.replace(self.course.id.org, bad_org) bad_team_url = self.teams_url.replace(self.course.id.org, bad_org)
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.client.login(username=self.user.username, password=self.test_password)
response = self.client.get(bad_team_url) response = self.client.get(bad_team_url)
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
...@@ -134,12 +140,12 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase): ...@@ -134,12 +140,12 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
self.test_team_1 = CourseTeamFactory.create( self.test_team_1 = CourseTeamFactory.create(
name=u'sólar team', name=u'sólar team',
course_id=self.test_course_1.id, course_id=self.test_course_1.id,
topic_id='renewable' topic_id='topic_0'
) )
self.test_team_2 = CourseTeamFactory.create(name='Wind Team', course_id=self.test_course_1.id) self.test_team_2 = CourseTeamFactory.create(name='Wind Team', course_id=self.test_course_1.id)
self.test_team_3 = CourseTeamFactory.create(name='Nuclear Team', course_id=self.test_course_1.id) self.test_team_3 = CourseTeamFactory.create(name='Nuclear Team', course_id=self.test_course_1.id)
self.test_team_4 = CourseTeamFactory.create(name='Coal Team', course_id=self.test_course_1.id, is_active=False) self.test_team_4 = CourseTeamFactory.create(name='Coal Team', course_id=self.test_course_1.id, is_active=False)
self.test_team_4 = CourseTeamFactory.create(name='Another Team', course_id=self.test_course_2.id) self.test_team_5 = CourseTeamFactory.create(name='Another Team', course_id=self.test_course_2.id)
for user, course in [ for user, course in [
('student_enrolled', self.test_course_1), ('student_enrolled', self.test_course_1),
...@@ -153,7 +159,7 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase): ...@@ -153,7 +159,7 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
self.test_team_1.add_user(self.users['student_enrolled']) self.test_team_1.add_user(self.users['student_enrolled'])
self.test_team_3.add_user(self.users['student_enrolled_both_courses_other_team']) self.test_team_3.add_user(self.users['student_enrolled_both_courses_other_team'])
self.test_team_4.add_user(self.users['student_enrolled_both_courses_other_team']) self.test_team_5.add_user(self.users['student_enrolled_both_courses_other_team'])
def login(self, user): def login(self, user):
"""Given a user string, logs the given user in. """Given a user string, logs the given user in.
...@@ -312,7 +318,7 @@ class TestListTeamsAPI(TeamAPITestCase): ...@@ -312,7 +318,7 @@ class TestListTeamsAPI(TeamAPITestCase):
self.verify_names({'course_id': self.test_course_2.id}, 200, ['Another Team'], user='staff') self.verify_names({'course_id': self.test_course_2.id}, 200, ['Another Team'], user='staff')
def test_filter_topic_id(self): def test_filter_topic_id(self):
self.verify_names({'course_id': self.test_course_1.id, 'topic_id': 'renewable'}, 200, [u'sólar team']) self.verify_names({'course_id': self.test_course_1.id, 'topic_id': 'topic_0'}, 200, [u'sólar team'])
def test_filter_include_inactive(self): def test_filter_include_inactive(self):
self.verify_names({'include_inactive': True}, 200, ['Coal Team', 'Nuclear Team', u'sólar team', 'Wind Team']) self.verify_names({'include_inactive': True}, 200, ['Coal Team', 'Nuclear Team', u'sólar team', 'Wind Team'])
...@@ -333,9 +339,10 @@ class TestListTeamsAPI(TeamAPITestCase): ...@@ -333,9 +339,10 @@ class TestListTeamsAPI(TeamAPITestCase):
data = {'order_by': field} if field else {} data = {'order_by': field} if field else {}
self.verify_names(data, status, names) self.verify_names(data, status, names)
@ddt.data({'course_id': 'no/such/course'}, {'topic_id': 'no_such_topic'}) @ddt.data((404, {'course_id': 'no/such/course'}), (400, {'topic_id': 'no_such_topic'}))
def test_no_results(self, data): @ddt.unpack
self.get_teams_list(404, data) def test_no_results(self, status, data):
self.get_teams_list(status, data)
def test_page_size(self): def test_page_size(self):
result = self.get_teams_list(200, {'page_size': 2}) result = self.get_teams_list(200, {'page_size': 2})
...@@ -348,7 +355,7 @@ class TestListTeamsAPI(TeamAPITestCase): ...@@ -348,7 +355,7 @@ class TestListTeamsAPI(TeamAPITestCase):
self.assertIsNotNone(result['previous']) self.assertIsNotNone(result['previous'])
def test_expand_user(self): def test_expand_user(self):
result = self.get_teams_list(200, {'expand': 'user', 'topic_id': 'renewable'}) result = self.get_teams_list(200, {'expand': 'user', 'topic_id': 'topic_0'})
self.verify_expanded_user(result['results'][0]['membership'][0]['user']) self.verify_expanded_user(result['results'][0]['membership'][0]['user'])
...@@ -561,6 +568,16 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -561,6 +568,16 @@ class TestListTopicsAPI(TeamAPITestCase):
response = self.get_topics_list(data={'course_id': self.test_course_1.id}) response = self.get_topics_list(data={'course_id': self.test_course_1.id})
self.assertEqual(response['sort_order'], 'name') self.assertEqual(response['sort_order'], 'name')
def test_team_count(self):
"""Test that team_count is included for each topic"""
response = self.get_topics_list(data={'course_id': self.test_course_1.id})
for topic in response['results']:
self.assertIn('team_count', topic)
if topic['id'] == u'topic_0':
self.assertEqual(topic['team_count'], 1)
else:
self.assertEqual(topic['team_count'], 0)
@ddt.ddt @ddt.ddt
class TestDetailTopicAPI(TeamAPITestCase): class TestDetailTopicAPI(TeamAPITestCase):
...@@ -588,6 +605,13 @@ class TestDetailTopicAPI(TeamAPITestCase): ...@@ -588,6 +605,13 @@ class TestDetailTopicAPI(TeamAPITestCase):
def test_invalid_topic_id(self): def test_invalid_topic_id(self):
self.get_topic_detail('no_such_topic', self.test_course_1.id, 404) self.get_topic_detail('no_such_topic', self.test_course_1.id, 404)
def test_team_count(self):
"""Test that team_count is included with a topic"""
topic = self.get_topic_detail(topic_id='topic_0', course_id=self.test_course_1.id)
self.assertEqual(topic['team_count'], 1)
topic = self.get_topic_detail(topic_id='topic_1', course_id=self.test_course_1.id)
self.assertEqual(topic['team_count'], 0)
@ddt.ddt @ddt.ddt
class TestListMembershipAPI(TeamAPITestCase): class TestListMembershipAPI(TeamAPITestCase):
......
"""Defines the URL routes for this app.""" """Defines the URL routes for this app."""
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from django.contrib.auth.decorators import login_required
from .views import TeamsDashboardView from .views import TeamsDashboardView
urlpatterns = patterns( urlpatterns = patterns(
'teams.views', 'teams.views',
url(r"^/$", TeamsDashboardView.as_view(), name="teams_dashboard") url(r"^/$", login_required(TeamsDashboardView.as_view()), name="teams_dashboard")
) )
...@@ -42,7 +42,14 @@ from opaque_keys import InvalidKeyError ...@@ -42,7 +42,14 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from .models import CourseTeam, CourseTeamMembership from .models import CourseTeam, CourseTeamMembership
from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, TopicSerializer, MembershipSerializer from .serializers import (
CourseTeamSerializer,
CourseTeamCreationSerializer,
BaseTopicSerializer,
TopicSerializer,
PaginatedTopicSerializer,
MembershipSerializer
)
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
...@@ -75,9 +82,15 @@ class TeamsDashboardView(View): ...@@ -75,9 +82,15 @@ class TeamsDashboardView(View):
sort_order = 'name' sort_order = 'name'
topics = get_ordered_topics(course, sort_order) topics = get_ordered_topics(course, sort_order)
topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1) topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1)
topics_serializer = PaginationSerializer(instance=topics_page, context={'sort_order': sort_order}) topics_serializer = PaginatedTopicSerializer(instance=topics_page, context={'sort_order': sort_order})
context = { context = {
"course": course, "topics": topics_serializer.data, "topics_url": reverse('topics_list', request=request) "course": course,
"topics": topics_serializer.data,
"topic_url": reverse(
'topics_detail', kwargs={'topic_id': 'topic_id', 'course_id': str(course_id)}, request=request
),
"topics_url": reverse('topics_list', request=request),
"teams_url": reverse('teams_list', request=request)
} }
return render_to_response("teams/teams.html", context) return render_to_response("teams/teams.html", context)
...@@ -248,7 +261,8 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -248,7 +261,8 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
try: try:
course_key = CourseKey.from_string(course_id_string) course_key = CourseKey.from_string(course_id_string)
# Ensure the course exists # Ensure the course exists
if not modulestore().has_course(course_key): course_module = modulestore().get_course(course_key)
if course_module is None:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
result_filter.update({'course_id': course_key}) result_filter.update({'course_id': course_key})
except InvalidKeyError: except InvalidKeyError:
...@@ -267,6 +281,13 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -267,6 +281,13 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
) )
if 'topic_id' in request.QUERY_PARAMS: if 'topic_id' in request.QUERY_PARAMS:
topic_id = request.QUERY_PARAMS['topic_id']
if topic_id not in [topic['id'] for topic in course_module.teams_configuration['topics']]:
error = build_api_error(
ugettext_noop('The supplied topic id {topic_id} is not valid'),
topic_id=topic_id
)
return Response(error, status=status.HTTP_400_BAD_REQUEST)
result_filter.update({'topic_id': request.QUERY_PARAMS['topic_id']}) result_filter.update({'topic_id': request.QUERY_PARAMS['topic_id']})
if 'include_inactive' in request.QUERY_PARAMS and request.QUERY_PARAMS['include_inactive'].lower() == 'true': if 'include_inactive' in request.QUERY_PARAMS and request.QUERY_PARAMS['include_inactive'].lower() == 'true':
del result_filter['is_active'] del result_filter['is_active']
...@@ -290,14 +311,17 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -290,14 +311,17 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
build_api_error(ugettext_noop("last_activity is not yet supported")), build_api_error(ugettext_noop("last_activity is not yet supported")),
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
else:
return Response({
'developer_message': "unsupported order_by value {}".format(order_by_input),
'user_message': _(u"The ordering {} is not supported").format(order_by_input),
}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.order_by(order_by_field) queryset = queryset.order_by(order_by_field)
if not queryset:
return Response(status=status.HTTP_404_NOT_FOUND)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
serializer = self.get_pagination_serializer(page) serializer = self.get_pagination_serializer(page)
serializer.context.update({'sort_order': order_by_input}) # pylint: disable=maybe-no-member
return Response(serializer.data) # pylint: disable=maybe-no-member return Response(serializer.data) # pylint: disable=maybe-no-member
def post(self, request): def post(self, request):
...@@ -492,8 +516,8 @@ class TopicListView(GenericAPIView): ...@@ -492,8 +516,8 @@ class TopicListView(GenericAPIView):
paginate_by = TOPICS_PER_PAGE paginate_by = TOPICS_PER_PAGE
paginate_by_param = 'page_size' paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer pagination_serializer_class = PaginatedTopicSerializer
serializer_class = TopicSerializer serializer_class = BaseTopicSerializer
def get(self, request): def get(self, request):
"""GET /api/team/v0/topics/?course_id={course_id}""" """GET /api/team/v0/topics/?course_id={course_id}"""
...@@ -531,8 +555,7 @@ class TopicListView(GenericAPIView): ...@@ -531,8 +555,7 @@ class TopicListView(GenericAPIView):
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
page = self.paginate_queryset(topics) page = self.paginate_queryset(topics)
serializer = self.get_pagination_serializer(page) serializer = self.pagination_serializer_class(page, context={'sort_order': ordering})
serializer.context = {'sort_order': ordering}
return Response(serializer.data) # pylint: disable=maybe-no-member return Response(serializer.data) # pylint: disable=maybe-no-member
......
...@@ -81,7 +81,8 @@ ...@@ -81,7 +81,8 @@
description: description, description: description,
action_class: this.callIfFunction(this.actionClass), action_class: this.callIfFunction(this.actionClass),
action_url: this.callIfFunction(this.actionUrl), action_url: this.callIfFunction(this.actionUrl),
action_content: this.callIfFunction(this.actionContent) action_content: this.callIfFunction(this.actionContent),
configuration: this.callIfFunction(this.configuration)
})); }));
var detailsEl = this.$el.find('.card-meta'); var detailsEl = this.$el.find('.card-meta');
_.each(this.callIfFunction(this.details), function (detail) { _.each(this.callIfFunction(this.details), function (detail) {
......
...@@ -19,35 +19,59 @@ ...@@ -19,35 +19,59 @@
* following properties: * following properties:
* view (Backbone.View): the view to render for this tab. * view (Backbone.View): the view to render for this tab.
* title (string): The title to display for this tab. * title (string): The title to display for this tab.
* url (string): The URL fragment which will navigate to this tab. * url (string): The URL fragment which will
* navigate to this tab when a router is
* provided.
* If a router is passed in (via options.router),
* use that router to keep track of history between
* tabs. Backbone.history.start() must be called
* by the router's instatiator after this view is
* initialized.
*/ */
initialize: function (options) { initialize: function (options) {
this.router = new Backbone.Router(); this.router = options.router || null;
this.$el.html(this.template({}));
var self = this;
this.tabs = options.tabs; this.tabs = options.tabs;
this.urlMap = _.reduce(this.tabs, function (map, value) {
map[value.url] = value;
return map;
}, {});
},
render: function () {
var self = this;
this.$el.html(this.template({}));
_.each(this.tabs, function(tabInfo, index) { _.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate, { var tabEl = $(_.template(tabTemplate, {
index: index, index: index,
title: tabInfo.title title: tabInfo.title,
url: tabInfo.url
})); }));
self.$('.page-content-nav').append(tabEl); self.$('.page-content-nav').append(tabEl);
self.router.route(tabInfo.url, function () {
self.setActiveTab(index);
});
}); });
this.setActiveTab(0); if(Backbone.history.getHash() === "") {
this.setActiveTab(0);
}
return this;
}, },
setActiveTab: function (index) { setActiveTab: function (index) {
var tab = this.tabs[index], var tab, tabEl, view;
view = tab.view; if (typeof index === 'string') {
tab = this.urlMap[index];
tabEl = this.$('a[data-url='+index+']');
}
else {
tab = this.tabs[index];
tabEl = this.$('a[data-index='+index+']');
}
view = tab.view;
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false'); this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false');
this.$('a[data-index='+index+']').addClass('is-active').attr('aria-selected', 'true'); tabEl.addClass('is-active').attr('aria-selected', 'true');
view.setElement(this.$('.page-content-main')).render(); view.setElement(this.$('.page-content-main')).render();
this.$('.sr-is-focusable.sr-tab').focus(); this.$('.sr-is-focusable.sr-tab').focus();
this.router.navigate(tab.url, {replace: true}); if (this.router) {
this.router.navigate(tab.url, {replace: true, trigger: true});
}
}, },
switchTab: function (event) { switchTab: function (event) {
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
it('can render itself as a list card', function () { it('can render itself as a list card', function () {
var view = new CardView({ configuration: 'list_card' }); var view = new CardView({ configuration: 'list_card' });
expect(view.$el).toHaveClass('list-card'); expect(view.$el).toHaveClass('list-card');
expect(view.$el.find('.wrapper-card-meta .action').length).toBe(1); expect(view.$el.find('.wrapper-card-core .action').length).toBe(1);
}); });
it('renders a pennant only if the pennant value is truthy', function () { it('renders a pennant only if the pennant value is truthy', function () {
......
...@@ -20,23 +20,15 @@ ...@@ -20,23 +20,15 @@
describe('TabbedView component', function () { describe('TabbedView component', function () {
beforeEach(function () { beforeEach(function () {
spyOn(Backbone.history, 'navigate').andCallThrough();
Backbone.history.start();
view = new TabbedView({ view = new TabbedView({
tabs: [{ tabs: [{
url: 'test 1',
title: 'Test 1', title: 'Test 1',
view: new TestSubview({text: 'this is test text'}) view: new TestSubview({text: 'this is test text'})
}, { }, {
url: 'test 2',
title: 'Test 2', title: 'Test 2',
view: new TestSubview({text: 'other text'}) view: new TestSubview({text: 'other text'})
}] }]
}); }).render();
});
afterEach(function () {
Backbone.history.stop();
}); });
it('can render itself', function () { it('can render itself', function () {
...@@ -59,12 +51,6 @@ ...@@ -59,12 +51,6 @@
expect(view.$el.text()).toContain('other text'); expect(view.$el.text()).toContain('other text');
}); });
it('changes tabs on navigation', function () {
expect(view.$('.nav-item.is-active').data('index')).toEqual(0);
Backbone.history.navigate('test 2', {trigger: true});
expect(view.$('.nav-item.is-active').data('index')).toEqual(1);
});
it('marks the active tab as selected using aria attributes', function () { it('marks the active tab as selected using aria attributes', function () {
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'true'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'true');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'false');
...@@ -73,17 +59,59 @@ ...@@ -73,17 +59,59 @@
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true');
}); });
it('updates the page URL on tab switches without adding to browser history', function () {
view.$('.nav-item[data-index=1]').click();
expect(Backbone.history.navigate).toHaveBeenCalledWith('test 2', {replace: true});
});
it('sets focus for screen readers', function () { it('sets focus for screen readers', function () {
spyOn($.fn, 'focus'); spyOn($.fn, 'focus');
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-index=1]').click();
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled(); expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled();
}); });
describe('history', function() {
beforeEach(function () {
spyOn(Backbone.history, 'navigate').andCallThrough();
view = new TabbedView({
tabs: [{
url: 'test 1',
title: 'Test 1',
view: new TestSubview({text: 'this is test text'})
}, {
url: 'test 2',
title: 'Test 2',
view: new TestSubview({text: 'other text'})
}],
router: new Backbone.Router({
routes: {
'test 1': function () {
view.setActiveTab(0);
},
'test 2': function () {
view.setActiveTab(1);
}
}
})
}).render();
Backbone.history.start();
});
afterEach(function () {
view.router.navigate('');
Backbone.history.stop();
});
it('updates the page URL on tab switches without adding to browser history', function () {
view.$('.nav-item[data-index=1]').click();
expect(Backbone.history.navigate).toHaveBeenCalledWith(
'test 2',
{replace: true, trigger: true}
);
});
it('changes tabs on URL navigation', function () {
expect(view.$('.nav-item.is-active').data('index')).toEqual(0);
Backbone.history.navigate('test 2', {trigger: true});
expect(view.$('.nav-item.is-active').data('index')).toEqual(1);
});
});
}); });
} });
);
}).call(this, define || RequireJS.define); }).call(this, define || RequireJS.define);
...@@ -531,6 +531,8 @@ ...@@ -531,6 +531,8 @@
'lms/include/teams/js/spec/topic_card_spec.js', 'lms/include/teams/js/spec/topic_card_spec.js',
'lms/include/teams/js/spec/topic_collection_spec.js', 'lms/include/teams/js/spec/topic_collection_spec.js',
'lms/include/teams/js/spec/topics_spec.js', 'lms/include/teams/js/spec/topics_spec.js',
'lms/include/teams/js/spec/teams_spec.js',
'lms/include/teams/js/spec/teams_tab_spec.js',
'lms/include/js/spec/components/header/header_spec.js', 'lms/include/js/spec/components/header/header_spec.js',
'lms/include/js/spec/components/tabbed/tabbed_view_spec.js', 'lms/include/js/spec/components/tabbed/tabbed_view_spec.js',
'lms/include/js/spec/components/card/card_spec.js', 'lms/include/js/spec/components/card/card_spec.js',
......
...@@ -76,9 +76,6 @@ ...@@ -76,9 +76,6 @@
// teams temporary // teams temporary
.view-teams { .view-teams {
.global-new, #global-navigation {
display: none;
}
// Copied from _pagination.scss in cms // Copied from _pagination.scss in cms
.pagination { .pagination {
......
...@@ -150,7 +150,7 @@ $yellow: rgb(255, 252, 221); ...@@ -150,7 +150,7 @@ $yellow: rgb(255, 252, 221);
// ==================== // ====================
// COLORS: old variables // COLORS: old variables
// DEPRECATED: use colors in lists above // DEPRECATED: use colors in lists above
$error-red: rgb(253, 87, 87); $error-red: rgb(253, 87, 87);
$danger-red: rgb(212, 64, 64); $danger-red: rgb(212, 64, 64);
$light-gray: rgb(221, 221, 221); $light-gray: rgb(221, 221, 221);
...@@ -163,7 +163,7 @@ $light-gray: rgb(221,221,221); // #dddddd ...@@ -163,7 +163,7 @@ $light-gray: rgb(221,221,221); // #dddddd
// ==================== // ====================
// used by descriptor css // used by descriptor css
// DEPRECATED: use colors in lists above // DEPRECATED: use colors in lists above
$lightGrey: rgb(237,241,245); // #edf1f5 $lightGrey: rgb(237,241,245); // #edf1f5
$darkGrey: rgb(136,145,161); // #8891a1 $darkGrey: rgb(136,145,161); // #8891a1
$lightGrey1: $gray-l3; $lightGrey1: $gray-l3;
...@@ -355,7 +355,7 @@ $highlight-color: rgb(255,255,0); ...@@ -355,7 +355,7 @@ $highlight-color: rgb(255,255,0);
// Notifications // Notifications
$notify-banner-bg-1: rgb(56,56,56); $notify-banner-bg-1: rgb(56,56,56);
$notify-banner-bg-2: rgb(136,136,136); $notify-banner-bg-2: rgb(136,136,136);
$notify-banner-bg-3: rgb(223,223,223); $notify-banner-bg-3: $shadow-l2;
$alert-color: rgb(212, 64, 64); //rich red $alert-color: rgb(212, 64, 64); //rich red
$warning-color: rgb(237, 189, 60); //rich yellow $warning-color: rgb(237, 189, 60); //rich yellow
......
// lms - elements - system feedback // lms - elements - system feedback
// ==================== // ====================
// messages // pre-pattern library messages
// UI : message // UI : message
.wrapper-msg { .wrapper-msg {
...@@ -111,6 +111,7 @@ ...@@ -111,6 +111,7 @@
&.urgency-low { &.urgency-low {
background: $notify-banner-bg-3; background: $notify-banner-bg-3;
box-shadow: 0 1px 2px $shadow;
.msg { .msg {
color: $black; color: $black;
...@@ -132,6 +133,16 @@ ...@@ -132,6 +133,16 @@
&.success { &.success {
border-top: 3px solid $success-color; border-top: 3px solid $success-color;
} }
&.is-incontext {
margin: $baseline;
.msg {
max-width: unset;
min-width: auto;
}
}
} }
......
<% if (configuration === 'square_card') { %>
<div class="wrapper-card-core"> <div class="wrapper-card-core">
<div class="card-core"> <div class="card-core">
<% if (pennant) { %> <% if (pennant) { %>
...@@ -14,3 +15,21 @@ ...@@ -14,3 +15,21 @@
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a> <a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
</div> </div>
</div> </div>
<% } else { %>
<div class="wrapper-card-core">
<div class="card-core">
<% if (pennant) { %>
<small class="card-type"><%- pennant %></small>
<% } %>
<h3 class="card-title"><%- title %></h3>
<p class="card-description"><%- description %></p>
</div>
<div class="card-actions">
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
</div>
</div>
<div class="wrapper-card-meta">
<div class="card-meta">
</div>
</div>
<% } %>
<a class="nav-item" href="" data-index="<%= index %>" role="tab" aria-selected="false"><%- title %></a> <a class="nav-item" href="" data-url="<%= url %>" data-index="<%= index %>" role="tab" aria-selected="false"><%- title %></a>
<div class="page-content"> <nav class="page-content-nav" aria-label="Teams"></nav>
<nav class="page-content-nav" aria-label="Teams"></nav> <div class="sr-is-focusable sr-tab" tabindex="-1"></div>
<div class="sr-is-focusable sr-tab" tabindex="-1"></div> <div class="page-content-main"></div>
<div class="page-content-main"></div>
</div>
...@@ -151,6 +151,15 @@ Teams | Course name ...@@ -151,6 +151,15 @@ Teams | Course name
</div> </div>
</div> </div>
</header> </header>
<div class="wrapper-msg is-incontext urgency-low warning">
<div class="msg">
<div class="msg-content">
<div class="copy">
<p>We couldn't find the team "blah".</p>
</div>
</div>
</div>
</div>
<div class="page-content"> <div class="page-content">
<nav class="page-content-nav" aria-label="Team"> <nav class="page-content-nav" aria-label="Team">
...@@ -159,7 +168,6 @@ Teams | Course name ...@@ -159,7 +168,6 @@ Teams | Course name
</nav> </nav>
<div class="page-content-main"> <div class="page-content-main">
<!-- may need a form with submit here -->
<div class="listing-tools"> <div class="listing-tools">
<span class="listing-count">1-10 of 24 topics</span> | <span class="listing-count">1-10 of 24 topics</span> |
<span class="field listing-sort"> <span class="field listing-sort">
......
...@@ -139,7 +139,16 @@ Create New Team | [Course name] ...@@ -139,7 +139,16 @@ Create New Team | [Course name]
<p class="page-description">If you cannot find an existing team to join or would like to team up with a group of friends, create a new team.</p> <p class="page-description">If you cannot find an existing team to join or would like to team up with a group of friends, create a new team.</p>
</div> </div>
</header> </header>
<div class="wrapper-msg is-incontext urgency-low warning">
<div class="msg">
<div class="msg-content">
<h3 class="title">Oops!</h3>
<div class="copy">
<p>We couldn't create your team because something needs to be fixed below.</p>
</div>
</div>
</div>
</div>
<div class="page-content"> <div class="page-content">
<form class="create-team"> <form class="create-team">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment