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 @@
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');
......
......@@ -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);
......
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 @@
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'
......
# -*- 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')
......@@ -19,7 +19,7 @@ TOPIC_ID_PATTERN = TEAM_ID_PATTERN.replace('team_id', 'topic_id')
urlpatterns = patterns(
'',
url(
r'^v0/teams$',
r'^v0/teams/$',
TeamsListView.as_view(),
name="teams_list"
),
......@@ -39,7 +39,7 @@ urlpatterns = patterns(
name="topics_detail"
),
url(
r'^v0/team_membership$',
r'^v0/team_membership/$',
MembershipListView.as_view(),
name="team_membership_list"
),
......
"""Defines serializers used by the Team API."""
from django.contrib.auth.models import User
from django.db.models import Count
from rest_framework import serializers
from openedx.core.lib.api.serializers import CollapsedReferenceSerializer
from openedx.core.lib.api.serializers import CollapsedReferenceSerializer, PaginationSerializer
from openedx.core.lib.api.fields import ExpandableField
from .models import CourseTeam, CourseTeamMembership
from openedx.core.djangoapps.user_api.serializers import UserSerializer
from .models import CourseTeam, CourseTeamMembership
class UserMembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with only user and date_joined
......@@ -108,8 +112,43 @@ class MembershipSerializer(serializers.ModelSerializer):
read_only_fields = ("date_joined",)
class TopicSerializer(serializers.Serializer):
"""Serializes a topic."""
class BaseTopicSerializer(serializers.Serializer):
"""Serializes a topic without team_count."""
description = serializers.CharField()
name = serializers.CharField()
id = serializers.CharField() # pylint: disable=invalid-name
class TopicSerializer(BaseTopicSerializer):
"""
Adds team_count to the basic topic serializer. Use only when
serializing a single topic. When serializing many topics, use
`PaginatedTopicSerializer` to avoid O(N) SQL queries.
"""
team_count = serializers.SerializerMethodField('get_team_count')
def get_team_count(self, topic):
"""Get the number of teams associated with this topic"""
return CourseTeam.objects.filter(topic_id=topic['id']).count()
class PaginatedTopicSerializer(PaginationSerializer):
"""Serializes a set of topics. Adds team_count field to each topic."""
class Meta(object):
"""Defines meta information for the PaginatedTopicSerializer."""
object_serializer_class = BaseTopicSerializer
def __init__(self, *args, **kwargs):
"""Adds team_count to each topic."""
super(PaginatedTopicSerializer, self).__init__(*args, **kwargs)
# The following query gets all the team_counts for each topic
# and outputs the result as a list of dicts (one per topic).
topic_ids = [topic['id'] for topic in self.data['results']]
teams_per_topic = CourseTeam.objects.filter(
topic_id__in=topic_ids
).values('topic_id').annotate(team_count=Count('topic_id'))
topics_to_team_count = {d['topic_id']: d['team_count'] for d in teams_per_topic}
for topic in self.data['results']:
topic['team_count'] = topics_to_team_count.get(topic['id'], 0)
;(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"],
beforeEach(function() {
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() {
......@@ -19,7 +25,7 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"],
});
it("displays a header", function() {
expect($("body").html()).toContain("Course teams are organized");
expect($("body").html()).toContain("See all teams in your course, organized by topic");
});
});
}
......
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([
'common/js/spec_helpers/ajax_helpers', 'teams/js/collections/topic', 'teams/js/views/topics'
], function (AjaxHelpers, TopicCollection, TopicsView) {
'teams/js/collections/topic', 'teams/js/views/topics'
], function (TopicCollection, TopicsView) {
'use strict';
describe('TopicsView', function () {
var initialTopics, topicCollection, topicsView, nextPageButtonCss;
nextPageButtonCss = '.next-page-link';
function generateTopics(startIndex, stopIndex) {
var initialTopics, topicCollection, topicsView,
generateTopics = function (startIndex, stopIndex) {
return _.map(_.range(startIndex, stopIndex + 1), function (i) {
return {
"description": "description " + i,
......@@ -16,7 +13,7 @@ define([
"team_count": 0
};
});
}
};
beforeEach(function () {
setFixtures('<div class="topics-container"></div>');
......@@ -34,143 +31,18 @@ define([
topicsView = new TopicsView({el: '.topics-container', collection: topicCollection}).render();
});
/**
* Verify that the topics view's header reflects the page we're currently viewing.
* @param matchString the header we expect to see
*/
function expectHeader(matchString) {
expect(topicsView.$('.topics-paging-header').text()).toMatch(matchString);
}
/**
* Verify that the topics list view renders the expected topics
* @param expectedTopics an array of topic objects we expect to see
*/
function expectTopics(expectedTopics) {
var topicCards;
topicCards = topicsView.$('.topic-card');
_.each(expectedTopics, function (topic, index) {
it('can render the first of many pages', function () {
var footerEl = topicsView.$('.topics-paging-footer'),
topicCards = topicsView.$('.topic-card');
expect(topicsView.$('.topics-paging-header').text()).toMatch('Showing 1-5 out of 6 total');
_.each(initialTopics, function (topic, index) {
var currentCard = topicCards.eq(index);
expect(currentCard.text()).toMatch(topic.name);
expect(currentCard.text()).toMatch(topic.description);
expect(currentCard.text()).toMatch(topic.team_count + ' Teams');
});
}
/**
* Verify that the topics footer reflects the current pagination
* @param options a parameters hash containing:
* - currentPage: the one-indexed page we expect to be viewing
* - totalPages: the total number of pages to page through
* - isHidden: whether the footer is expected to be visible
*/
function expectFooter(options) {
var footerEl = topicsView.$('.topics-paging-footer');
expect(footerEl.text())
.toMatch(new RegExp(options.currentPage + '\\s+out of\\s+\/\\s+' + topicCollection.totalPages));
expect(footerEl.hasClass('hidden')).toBe(options.isHidden);
}
it('can render the first of many pages', function () {
expectHeader('Showing 1-5 out of 6 total');
expectTopics(initialTopics);
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
});
it('can render the only page', function () {
initialTopics = generateTopics(1, 1);
topicCollection.set(
{
"count": 1,
"num_pages": 1,
"current_page": 1,
"start": 0,
"results": initialTopics
},
{parse: true}
);
expectHeader('Showing 1 out of 1 total');
expectTopics(initialTopics);
expectFooter({currentPage: 1, totalPages: 1, isHidden: true});
});
it('can change to the next page', function () {
var requests = AjaxHelpers.requests(this),
newTopics = generateTopics(1, 1);
expectHeader('Showing 1-5 out of 6 total');
expectTopics(initialTopics);
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
expect(requests.length).toBe(0);
topicsView.$(nextPageButtonCss).click();
expect(requests.length).toBe(1);
AjaxHelpers.respondWithJson(requests, {
"count": 6,
"num_pages": 2,
"current_page": 2,
"start": 5,
"results": newTopics
});
expectHeader('Showing 6-6 out of 6 total');
expectTopics(newTopics);
expectFooter({currentPage: 2, totalPages: 2, isHidden: false});
});
it('can change to the previous page', function () {
var requests = AjaxHelpers.requests(this),
previousPageTopics;
initialTopics = generateTopics(1, 1);
topicCollection.set(
{
"count": 6,
"num_pages": 2,
"current_page": 2,
"start": 5,
"results": initialTopics
},
{parse: true}
);
expectHeader('Showing 6-6 out of 6 total');
expectTopics(initialTopics);
expectFooter({currentPage: 2, totalPages: 2, isHidden: false});
topicsView.$('.previous-page-link').click();
previousPageTopics = generateTopics(1, 5);
AjaxHelpers.respondWithJson(requests, {
"count": 6,
"num_pages": 2,
"current_page": 1,
"start": 0,
"results": previousPageTopics
});
expectHeader('Showing 1-5 out of 6 total');
expectTopics(previousPageTopics);
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
});
it('sets focus for screen readers', function () {
var requests = AjaxHelpers.requests(this);
spyOn($.fn, 'focus');
topicsView.$(nextPageButtonCss).click();
AjaxHelpers.respondWithJson(requests, {
"count": 6,
"num_pages": 2,
"current_page": 2,
"start": 5,
"results": generateTopics(1, 1)
});
expect(topicsView.$('.sr-is-focusable').focus).toHaveBeenCalled();
});
it('does not change on server error', function () {
var requests = AjaxHelpers.requests(this),
expectInitialState = function () {
expectHeader('Showing 1-5 out of 6 total');
expectTopics(initialTopics);
expectFooter({currentPage: 1, totalPages: 2, isHidden: false});
};
expectInitialState();
topicsView.$(nextPageButtonCss).click();
requests[0].respond(500);
expectInitialState();
expect(footerEl.text()).toMatch('1\\s+out of\\s+\/\\s+2');
expect(footerEl).not.toHaveClass('hidden');
});
});
});
;(function (define) {
'use strict';
define(['jquery', 'teams/js/views/teams_tab', 'teams/js/collections/topic'],
function ($, TeamsTabView, TopicCollection) {
return function (element, topics, topics_url, course_id) {
var topicCollection = new TopicCollection(topics, {url: topics_url, course_id: course_id, parse: true});
topicCollection.bootstrap();
var view = new TeamsTabView({
el: element,
topicCollection: topicCollection
});
view.render();
define(['jquery', 'teams/js/views/teams_tab'],
function ($, TeamsTabView) {
return function (options) {
var teamsTab = new TeamsTabView(_.extend(options, {el: $('.teams-content')}));
teamsTab.render();
Backbone.history.start();
};
});
}).call(this, define || RequireJS.define);
;(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);
......@@ -32,7 +32,7 @@
action: function (event) {
event.preventDefault();
// TODO implement actual navigation
this.router.navigate('topics/' + this.model.get('id'), {trigger: true});
},
configuration: 'square_card',
......
;(function (define) {
'use strict';
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',
'text!teams/templates/topics.underscore'
], function (Backbone, _, gettext, ListView, PagingHeader, PagingFooterView, TopicCardView, topics_template) {
var TopicsListView = ListView.extend({
tagName: 'div',
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;
},
'common/js/components/views/paginated_view'
], function (TopicCardView, PaginatedView) {
var TopicsView = PaginatedView.extend({
type: 'topics',
/**
* Helper method to render subviews and re-bind events.
*
* 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();
initialize: function (options) {
this.itemViewClass = TopicCardView.extend({router: options.router});
PaginatedView.prototype.initialize.call(this);
}
});
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 @@
<%block name="js_extra">
<%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>
</%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
import ddt
from django.core.urlresolvers import reverse
from django.conf import settings
from nose.plugins.attrib import attr
from rest_framework.test import APITestCase, APIClient
......@@ -35,13 +36,16 @@ class TestDashboard(ModuleStoreTestCase):
self.teams_url = reverse('teams_dashboard', args=[self.course.id])
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()
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):
""" 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)
self.assertEqual(404, response.status_code)
......@@ -82,6 +86,8 @@ class TestDashboard(ModuleStoreTestCase):
"""
bad_org = "badorgxxx"
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)
self.assertEqual(404, response.status_code)
......@@ -134,12 +140,12 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
self.test_team_1 = CourseTeamFactory.create(
name=u'sólar team',
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_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='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 [
('student_enrolled', self.test_course_1),
......@@ -153,7 +159,7 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
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_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):
"""Given a user string, logs the given user in.
......@@ -312,7 +318,7 @@ class TestListTeamsAPI(TeamAPITestCase):
self.verify_names({'course_id': self.test_course_2.id}, 200, ['Another Team'], user='staff')
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):
self.verify_names({'include_inactive': True}, 200, ['Coal Team', 'Nuclear Team', u'sólar team', 'Wind Team'])
......@@ -333,9 +339,10 @@ class TestListTeamsAPI(TeamAPITestCase):
data = {'order_by': field} if field else {}
self.verify_names(data, status, names)
@ddt.data({'course_id': 'no/such/course'}, {'topic_id': 'no_such_topic'})
def test_no_results(self, data):
self.get_teams_list(404, data)
@ddt.data((404, {'course_id': 'no/such/course'}), (400, {'topic_id': 'no_such_topic'}))
@ddt.unpack
def test_no_results(self, status, data):
self.get_teams_list(status, data)
def test_page_size(self):
result = self.get_teams_list(200, {'page_size': 2})
......@@ -348,7 +355,7 @@ class TestListTeamsAPI(TeamAPITestCase):
self.assertIsNotNone(result['previous'])
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'])
......@@ -561,6 +568,16 @@ class TestListTopicsAPI(TeamAPITestCase):
response = self.get_topics_list(data={'course_id': self.test_course_1.id})
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
class TestDetailTopicAPI(TeamAPITestCase):
......@@ -588,6 +605,13 @@ class TestDetailTopicAPI(TeamAPITestCase):
def test_invalid_topic_id(self):
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
class TestListMembershipAPI(TeamAPITestCase):
......
"""Defines the URL routes for this app."""
from django.conf.urls import patterns, url
from django.contrib.auth.decorators import login_required
from .views import TeamsDashboardView
urlpatterns = patterns(
'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
from opaque_keys.edx.keys import CourseKey
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
......@@ -75,9 +82,15 @@ class TeamsDashboardView(View):
sort_order = 'name'
topics = get_ordered_topics(course, sort_order)
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 = {
"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)
......@@ -248,7 +261,8 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
try:
course_key = CourseKey.from_string(course_id_string)
# 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)
result_filter.update({'course_id': course_key})
except InvalidKeyError:
......@@ -267,6 +281,13 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
)
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']})
if 'include_inactive' in request.QUERY_PARAMS and request.QUERY_PARAMS['include_inactive'].lower() == 'true':
del result_filter['is_active']
......@@ -290,14 +311,17 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
build_api_error(ugettext_noop("last_activity is not yet supported")),
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)
if not queryset:
return Response(status=status.HTTP_404_NOT_FOUND)
page = self.paginate_queryset(queryset)
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
def post(self, request):
......@@ -492,8 +516,8 @@ class TopicListView(GenericAPIView):
paginate_by = TOPICS_PER_PAGE
paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer
serializer_class = TopicSerializer
pagination_serializer_class = PaginatedTopicSerializer
serializer_class = BaseTopicSerializer
def get(self, request):
"""GET /api/team/v0/topics/?course_id={course_id}"""
......@@ -531,8 +555,7 @@ class TopicListView(GenericAPIView):
}, status=status.HTTP_400_BAD_REQUEST)
page = self.paginate_queryset(topics)
serializer = self.get_pagination_serializer(page)
serializer.context = {'sort_order': ordering}
serializer = self.pagination_serializer_class(page, context={'sort_order': ordering})
return Response(serializer.data) # pylint: disable=maybe-no-member
......
......@@ -81,7 +81,8 @@
description: description,
action_class: this.callIfFunction(this.actionClass),
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');
_.each(this.callIfFunction(this.details), function (detail) {
......
......@@ -19,35 +19,59 @@
* following properties:
* view (Backbone.View): the view to render 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) {
this.router = new Backbone.Router();
this.$el.html(this.template({}));
var self = this;
this.router = options.router || null;
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) {
var tabEl = $(_.template(tabTemplate, {
index: index,
title: tabInfo.title
title: tabInfo.title,
url: tabInfo.url
}));
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) {
var tab = this.tabs[index],
view = tab.view;
var tab, tabEl, 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[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();
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) {
......
......@@ -18,7 +18,7 @@
it('can render itself as a list card', function () {
var view = new CardView({ configuration: '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 () {
......
......@@ -20,23 +20,15 @@
describe('TabbedView component', function () {
beforeEach(function () {
spyOn(Backbone.history, 'navigate').andCallThrough();
Backbone.history.start();
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'})
}]
});
});
afterEach(function () {
Backbone.history.stop();
}).render();
});
it('can render itself', function () {
......@@ -59,12 +51,6 @@
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 () {
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'true');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'false');
......@@ -73,17 +59,59 @@
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 () {
spyOn($.fn, 'focus');
view.$('.nav-item[data-index=1]').click();
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);
......@@ -531,6 +531,8 @@
'lms/include/teams/js/spec/topic_card_spec.js',
'lms/include/teams/js/spec/topic_collection_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/tabbed/tabbed_view_spec.js',
'lms/include/js/spec/components/card/card_spec.js',
......
......@@ -76,9 +76,6 @@
// teams temporary
.view-teams {
.global-new, #global-navigation {
display: none;
}
// Copied from _pagination.scss in cms
.pagination {
......
......@@ -150,7 +150,7 @@ $yellow: rgb(255, 252, 221);
// ====================
// COLORS: old variables
// DEPRECATED: use colors in lists above
// DEPRECATED: use colors in lists above
$error-red: rgb(253, 87, 87);
$danger-red: rgb(212, 64, 64);
$light-gray: rgb(221, 221, 221);
......@@ -163,7 +163,7 @@ $light-gray: rgb(221,221,221); // #dddddd
// ====================
// used by descriptor css
// DEPRECATED: use colors in lists above
// DEPRECATED: use colors in lists above
$lightGrey: rgb(237,241,245); // #edf1f5
$darkGrey: rgb(136,145,161); // #8891a1
$lightGrey1: $gray-l3;
......@@ -355,7 +355,7 @@ $highlight-color: rgb(255,255,0);
// Notifications
$notify-banner-bg-1: rgb(56,56,56);
$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
$warning-color: rgb(237, 189, 60); //rich yellow
......
// lms - elements - system feedback
// ====================
// messages
// pre-pattern library messages
// UI : message
.wrapper-msg {
......@@ -111,6 +111,7 @@
&.urgency-low {
background: $notify-banner-bg-3;
box-shadow: 0 1px 2px $shadow;
.msg {
color: $black;
......@@ -132,6 +133,16 @@
&.success {
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="card-core">
<% if (pennant) { %>
......@@ -14,3 +15,21 @@
<a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
</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>
<div class="sr-is-focusable sr-tab" tabindex="-1"></div>
<div class="page-content-main"></div>
</div>
<nav class="page-content-nav" aria-label="Teams"></nav>
<div class="sr-is-focusable sr-tab" tabindex="-1"></div>
<div class="page-content-main"></div>
......@@ -151,6 +151,15 @@ Teams | Course name
</div>
</div>
</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">
<nav class="page-content-nav" aria-label="Team">
......@@ -159,7 +168,6 @@ Teams | Course name
</nav>
<div class="page-content-main">
<!-- may need a form with submit here -->
<div class="listing-tools">
<span class="listing-count">1-10 of 24 topics</span> |
<span class="field listing-sort">
......
......@@ -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>
</div>
</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">
<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