diff --git a/common/test/acceptance/pages/lms/teams.py b/common/test/acceptance/pages/lms/teams.py
index be6fa55cd7..daeeb6e3c4 100644
--- a/common/test/acceptance/pages/lms/teams.py
+++ b/common/test/acceptance/pages/lms/teams.py
@@ -312,3 +312,25 @@ class TeamPage(CoursePage, PaginatedUIMixin):
def team_invite_url(self):
"""Returns the url of invite link box"""
return self.q(css='.page-content-secondary .invite-link-input').attrs('value')[0]
+
+ def click_join_team_button(self):
+ """ Click on Join Team button"""
+ self.q(css='.join-team .action-primary').first.click()
+ self.wait_for_ajax()
+
+ @property
+ def join_team_message(self):
+ """ Returns join team message """
+ self.wait_for_ajax()
+ return self.q(css='.join-team .join-team-message').text[0]
+
+ @property
+ def join_team_button_present(self):
+ """ Returns True if Join Team button is present else False """
+ self.wait_for_ajax()
+ return self.q(css='.join-team .action-primary').present
+
+ @property
+ def join_team_message_present(self):
+ """ Returns True if Join Team message is present else False """
+ return self.q(css='.join-team .join-team-message').present
diff --git a/common/test/acceptance/tests/lms/test_teams.py b/common/test/acceptance/tests/lms/test_teams.py
index 8ef68ed31a..cc57aaeadf 100644
--- a/common/test/acceptance/tests/lms/test_teams.py
+++ b/common/test/acceptance/tests/lms/test_teams.py
@@ -997,3 +997,61 @@ class TeamPageTest(TeamsTabBase):
invite_text='Send this link to friends so that they can join too.'
)
self.assertEqual(self.team_page.team_invite_url, '{0}?invite=true'.format(self.team_page.url))
+
+ def test_join_team(self):
+ """
+ Scenario: User can join a Team if not a member already..
+
+ Given I am enrolled in a course with a team configuration, a topic,
+ and a team belonging to that topic
+ And I visit the Team page for that team
+ Then I should see Join Team button
+ When I click on Join Team button
+ Then there should be no Join Team button and no message
+ And I should see the updated information under Team Details
+ """
+ self._set_team_configuration_and_membership(create_membership=False)
+ self.team_page.visit()
+ self.assertTrue(self.team_page.join_team_button_present)
+ self.team_page.click_join_team_button()
+ self.assertFalse(self.team_page.join_team_button_present)
+ self.assertFalse(self.team_page.join_team_message_present)
+ self.assert_team_details(num_members=1, invite_text='Send this link to friends so that they can join too.')
+
+ def test_already_member_message(self):
+ """
+ Scenario: User should see `You are already in a team` if user is a
+ member of other team.
+
+ Given I am enrolled in a course with a team configuration, a topic,
+ and a team belonging to that topic
+ And I am already a member of a team
+ And I visit a team other than mine
+ Then I should see `You are already in a team` message
+ """
+ self._set_team_configuration_and_membership(membership_team_index=0, visit_team_index=1)
+ self.team_page.visit()
+ self.assertEqual(self.team_page.join_team_message, 'You already belong to another team.')
+ self.assert_team_details(num_members=0, is_member=False)
+
+ def test_team_full_message(self):
+ """
+ Scenario: User should see `Team is full` message when team is full.
+
+ Given I am enrolled in a course with a team configuration, a topic,
+ and a team belonging to that topic
+ And team has no space left
+ And I am not a member of any team
+ And I visit the team
+ Then I should see `Team is full` message
+ """
+ self._set_team_configuration_and_membership(
+ create_membership=True,
+ max_team_size=1,
+ membership_team_index=0,
+ visit_team_index=0,
+ another_user=True
+ )
+ self.team_page.visit()
+ self.assertEqual(self.team_page.join_team_message, 'This team is full.')
+ self.assert_team_details(num_members=1, is_member=False, max_size=1)
diff --git a/lms/djangoapps/teams/static/teams/js/spec/views/team_join_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/team_join_spec.js
new file mode 100644
index 0000000000..2ca1afdebe
--- /dev/null
+++ b/lms/djangoapps/teams/static/teams/js/spec/views/team_join_spec.js
@@ -0,0 +1,175 @@
+define([
+ 'underscore', 'common/js/spec_helpers/ajax_helpers', 'teams/js/models/team',
+ 'teams/js/views/team_join', 'teams/js/views/team_profile'
+], function (_, AjaxHelpers, TeamModel, TeamJoinView, TeamProfileView) {
+ 'use strict';
+ describe('TeamJoinView', function () {
+ var createTeamsUrl,
+ createTeamModelData,
+ createMembershipData,
+ createJoinView,
+ ACCOUNTS_API_URL = '/api/user/v1/accounts/',
+ TEAMS_URL = '/api/team/v0/teams/',
+ TEAMS_MEMBERSHIP_URL = '/api/team/v0/team_membership/';
+
+ beforeEach(function () {
+ setFixtures(
+ '
'
+ );
+ });
+
+ createTeamsUrl = function (teamId) {
+ return TEAMS_URL + teamId + '?expand=user';
+ };
+
+ createTeamModelData = function (teamId, teamName, membership) {
+ return {
+ id: teamId,
+ name: teamName,
+ membership: membership
+ };
+ };
+
+ createMembershipData = function (username) {
+ return [
+ {
+ "user": {
+ "username": username,
+ "url": ACCOUNTS_API_URL + username
+ }
+ }
+ ];
+ };
+
+ createJoinView = function(maxTeamSize, currentUsername, teamModelData, teamId) {
+ teamId = teamId || 'teamA';
+
+ var model = new TeamModel(teamModelData, { parse: true });
+ model.url = createTeamsUrl(teamId);
+
+ var teamJoinView = new TeamJoinView(
+ {
+ model: model,
+ teamsUrl: createTeamsUrl(teamId),
+ maxTeamSize: maxTeamSize,
+ currentUsername: currentUsername,
+ teamMembershipsUrl: TEAMS_MEMBERSHIP_URL
+ }
+ );
+ return teamJoinView.render();
+ };
+
+ it('can render itself', function () {
+ var teamModelData = createTeamModelData('teamA', 'teamAlpha', createMembershipData('ma'));
+ var view = createJoinView(1, 'ma', teamModelData);
+
+ expect(view.$('.join-team').length).toEqual(1);
+ });
+
+ it('can join team successfully', function () {
+ var requests = AjaxHelpers.requests(this);
+ var currentUsername = 'ma1';
+ var teamId = 'teamA';
+ var teamName = 'teamAlpha';
+ var teamModelData = createTeamModelData(teamId, teamName, []);
+ var view = createJoinView(1, currentUsername, teamModelData);
+
+ // a get request will be sent to get user membership info
+ // because current user is not member of current team
+ AjaxHelpers.expectRequest(
+ requests,
+ 'GET',
+ TEAMS_MEMBERSHIP_URL + '?' + $.param({"username": currentUsername})
+ );
+
+ // current user is not a member of any team so we should see the Join Team button
+ AjaxHelpers.respondWithJson(requests, {"count": 0});
+ expect(view.$('.action.action-primary').length).toEqual(1);
+
+ // a post request will be sent to add current user to current team
+ view.$('.action.action-primary').click();
+ AjaxHelpers.expectRequest(
+ requests,
+ 'POST',
+ TEAMS_MEMBERSHIP_URL,
+ $.param({'username': currentUsername, 'team_id': teamId})
+ );
+ AjaxHelpers.respondWithJson(requests, {});
+
+ // on success, team model will be fetched and
+ // join team view and team profile will be re-rendered
+ AjaxHelpers.expectRequest(
+ requests,
+ 'GET',
+ createTeamsUrl(teamId)
+ );
+ AjaxHelpers.respondWithJson(
+ requests, createTeamModelData(teamId, teamName, createMembershipData(currentUsername))
+ );
+
+ // current user is now member of the current team then there should be no button and no message
+ expect(view.$('.action.action-primary').length).toEqual(0);
+ expect(view.$('.join-team-message').length).toEqual(0);
+ });
+
+ it('shows already member message', function () {
+ var requests = AjaxHelpers.requests(this);
+ var currentUsername = 'ma1';
+ var view = createJoinView(1, currentUsername, createTeamModelData('teamA', 'teamAlpha', []));
+
+ // a get request will be sent to get user membership info
+ // because current user is not member of current team
+ AjaxHelpers.expectRequest(
+ requests,
+ 'GET',
+ TEAMS_MEMBERSHIP_URL + '?' + $.param({"username": currentUsername})
+ );
+
+ // current user is a member of another team so we should see the correct message
+ AjaxHelpers.respondWithJson(requests, {"count": 1});
+ expect(view.$('.action.action-primary').length).toEqual(0);
+ expect(view.$('.join-team-message').text().trim()).toBe(view.alreadyMemberMessage);
+ });
+
+ it('shows team full message', function () {
+ var requests = AjaxHelpers.requests(this);
+ var view = createJoinView(
+ 1,
+ 'ma1',
+ createTeamModelData('teamA', 'teamAlpha', createMembershipData('ma'))
+ );
+
+ // team has no space and current user is a not member of
+ // current team so we should see the correct message
+ expect(view.$('.action.action-primary').length).toEqual(0);
+ expect(view.$('.join-team-message').text().trim()).toBe(view.teamFullMessage);
+
+ // there should be no request made
+ expect(requests.length).toBe(0);
+ });
+
+ it('shows correct error messages', function () {
+ var requests = AjaxHelpers.requests(this);
+
+ var verifyErrorMessage = function (requests, errorMessage, expectedMessage) {
+ createJoinView(1, 'ma', createTeamModelData('teamA', 'teamAlpha', []));
+ AjaxHelpers.respondWithTextError(requests, 400, errorMessage);
+ expect($('.msg-content .copy').text().trim()).toBe(expectedMessage);
+ };
+
+ // verify user_message
+ verifyErrorMessage(
+ requests,
+ JSON.stringify({'user_message': 'Awesome! You got an error.'}),
+ 'Awesome! You got an error.'
+ );
+
+ // verify generic error message
+ verifyErrorMessage(
+ requests,
+ '',
+ 'An error occurred. Try again.'
+ );
+ });
+ });
+});
diff --git a/lms/djangoapps/teams/static/teams/js/views/team_join.js b/lms/djangoapps/teams/static/teams/js/views/team_join.js
new file mode 100644
index 0000000000..213ba0cf5d
--- /dev/null
+++ b/lms/djangoapps/teams/static/teams/js/views/team_join.js
@@ -0,0 +1,123 @@
+;(function (define) {
+'use strict';
+
+define(['backbone',
+ 'underscore',
+ 'gettext',
+ 'teams/js/views/team_utils',
+ 'text!teams/templates/team-join.underscore'],
+ function (Backbone, _, gettext, TeamUtils, teamJoinTemplate) {
+ return Backbone.View.extend({
+
+ errorMessage: gettext("An error occurred. Try again."),
+ alreadyMemberMessage: gettext("You already belong to another team."),
+ teamFullMessage: gettext("This team is full."),
+
+ events: {
+ "click .action-primary": "joinTeam"
+ },
+
+ initialize: function(options) {
+ this.template = _.template(teamJoinTemplate);
+ this.maxTeamSize = options.maxTeamSize;
+ this.currentUsername = options.currentUsername;
+ this.teamMembershipsUrl = options.teamMembershipsUrl;
+ _.bindAll(this, 'render', 'joinTeam', 'getUserTeamInfo');
+ this.listenTo(this.model, "change", this.render);
+ },
+
+ render: function() {
+ var message,
+ showButton,
+ teamHasSpace;
+
+ var view = this;
+ this.getUserTeamInfo(this.currentUsername, view.maxTeamSize).done(function (info) {
+ teamHasSpace = info.teamHasSpace;
+
+ // if user is the member of current team then we wouldn't show anything
+ if (!info.memberOfCurrentTeam) {
+ showButton = !info.alreadyMember && teamHasSpace;
+
+ if (info.alreadyMember) {
+ message = info.memberOfCurrentTeam ? '' : view.alreadyMemberMessage;
+ } else if (!teamHasSpace) {
+ message = view.teamFullMessage;
+ }
+ }
+
+ view.$el.html(view.template({showButton: showButton, message: message}));
+ });
+ return view;
+ },
+
+ joinTeam: function () {
+ var view = this;
+ $.ajax({
+ type: 'POST',
+ url: view.teamMembershipsUrl,
+ data: {'username': view.currentUsername, 'team_id': view.model.get('id')}
+ }).done(function (data) {
+ view.model.fetch({});
+ }).fail(function (data) {
+ try {
+ var errors = JSON.parse(data.responseText);
+ view.showMessage(errors.user_message);
+ } catch (error) {
+ view.showMessage(view.errorMessage);
+ }
+ });
+ },
+
+ getUserTeamInfo: function (username, maxTeamSize) {
+ var deferred = $.Deferred();
+ var info = {
+ alreadyMember: false,
+ memberOfCurrentTeam: false,
+ teamHasSpace: false
+ };
+
+ info.memberOfCurrentTeam = TeamUtils.isUserMemberOfTeam(this.model.get('membership'), username);
+ var teamHasSpace = this.model.get('membership').length < maxTeamSize;
+
+ if (info.memberOfCurrentTeam) {
+ info.alreadyMember = true;
+ info.memberOfCurrentTeam = true;
+ deferred.resolve(info);
+ } else {
+ if (teamHasSpace) {
+ var view = this;
+ $.ajax({
+ type: 'GET',
+ url: view.teamMembershipsUrl,
+ data: {'username': username}
+ }).done(function (data) {
+ info.alreadyMember = (data.count > 0);
+ info.memberOfCurrentTeam = false;
+ info.teamHasSpace = teamHasSpace;
+ deferred.resolve(info);
+ }).fail(function (data) {
+ try {
+ var errors = JSON.parse(data.responseText);
+ view.showMessage(errors.user_message);
+ } catch (error) {
+ view.showMessage(view.errorMessage);
+ }
+ deferred.reject();
+ });
+ } else {
+ deferred.resolve(info);
+ }
+ }
+
+ return deferred.promise();
+ },
+
+ showMessage: function (message) {
+ $('.wrapper-msg').removeClass('is-hidden');
+ $('.msg-content .copy').text(message);
+ $('.wrapper-msg').focus();
+ }
+ });
+ });
+}).call(this, define || RequireJS.define);
diff --git a/lms/djangoapps/teams/static/teams/js/views/team_profile.js b/lms/djangoapps/teams/static/teams/js/views/team_profile.js
index 90ca3fbfd2..f6ee371f76 100644
--- a/lms/djangoapps/teams/static/teams/js/views/team_profile.js
+++ b/lms/djangoapps/teams/static/teams/js/views/team_profile.js
@@ -15,10 +15,9 @@
'click .invite-link-input': 'selectText'
},
initialize: function (options) {
+ this.listenTo(this.model, "change", this.render);
this.courseID = options.courseID;
- this.discussionTopicID = this.model.get('discussion_topic_id');
this.maxTeamSize = options.maxTeamSize;
- this.memberships = this.model.get('membership');
this.readOnly = options.readOnly;
this.requestUsername = options.requestUsername;
this.teamInviteUrl = options.teamInviteUrl;
@@ -29,15 +28,17 @@
},
render: function () {
+ var memberships = this.model.get('membership');
+ var discussionTopicID = this.model.get('discussion_topic_id');
this.$el.html(_.template(teamTemplate, {
courseID: this.courseID,
- discussionTopicID: this.discussionTopicID,
+ discussionTopicID: discussionTopicID,
readOnly: this.readOnly,
country: this.countries[this.model.get('country')],
language: this.languages[this.model.get('language')],
- membershipText: TeamUtils.teamCapacityText(this.memberships.length, this.maxTeamSize),
- isMember: TeamUtils.isUserMemberOfTeam(this.memberships, this.requestUsername),
- hasCapacity: this.memberships.length < this.maxTeamSize,
+ membershipText: TeamUtils.teamCapacityText(memberships.length, this.maxTeamSize),
+ isMember: TeamUtils.isUserMemberOfTeam(memberships, this.requestUsername),
+ hasCapacity: memberships.length < this.maxTeamSize,
inviteLink: this.teamInviteUrl
}));
@@ -52,7 +53,7 @@
renderTeamMembers: function() {
var view = this;
- _.each(this.memberships, function(membership) {
+ _.each(this.model.get('membership'), function(membership) {
view.$('.members-info').append(_.template(teamMemberTemplate, {
imageUrl: membership.user.profile_image.image_url_medium,
username: membership.user.username,
diff --git a/lms/djangoapps/teams/static/teams/js/views/teams_tab.js b/lms/djangoapps/teams/static/teams/js/views/teams_tab.js
index 9e0beec554..c43038d1a7 100644
--- a/lms/djangoapps/teams/static/teams/js/views/teams_tab.js
+++ b/lms/djangoapps/teams/static/teams/js/views/teams_tab.js
@@ -17,11 +17,12 @@
'teams/js/views/my_teams',
'teams/js/views/topic_teams',
'teams/js/views/edit_team',
+ 'teams/js/views/team_join',
'text!teams/templates/teams_tab.underscore'],
function (Backbone, _, gettext, HeaderView, HeaderModel, TabbedView,
TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection,
TopicsView, TeamProfileView, MyTeamsView, TopicTeamsView, TeamEditView,
- teamsTemplate) {
+ TeamJoinView, teamsTemplate) {
var TeamsHeaderModel = HeaderModel.extend({
initialize: function (attributes) {
_.extend(this.defaults, {nav_aria_label: gettext('teams')});
@@ -241,7 +242,14 @@
countries: self.countries
}
});
- deferred.resolve(self.createViewWithHeader(teamsView, topic));
+ deferred.resolve(
+ self.createViewWithHeader(
+ {
+ mainView: teamsView,
+ subject: topic
+ }
+ )
+ );
});
});
}
@@ -277,33 +285,52 @@
requestUsername: self.userInfo.username,
countries: self.countries,
languages: self.languages,
- teamInviteUrl: self.teamsBaseUrl + '#teams/' + topicID + '/' + teamID + '?invite=true'
+ teamInviteUrl: self.teamsBaseUrl + '#teams/' + topicID + '/' + teamID + '?invite=true'
});
- deferred.resolve(self.createViewWithHeader(view, team, topic));
+ var teamJoinView = new TeamJoinView(
+ {
+ model: team,
+ teamsUrl: self.teamsUrl,
+ maxTeamSize: self.maxTeamSize,
+ currentUsername: self.userInfo.username,
+ teamMembershipsUrl: self.teamMembershipsUrl
+ }
+ );
+ deferred.resolve(
+ self.createViewWithHeader(
+ {
+ mainView: view,
+ subject: team,
+ parentTopic: topic,
+ headerActionsView: teamJoinView
+ }
+ )
+ );
});
});
return deferred.promise();
},
- createViewWithHeader: function (mainView, subject, parentTopic) {
+ createViewWithHeader: function (options) {
var router = this.router,
breadcrumbs, headerView;
breadcrumbs = [{
title: gettext('All Topics'),
url: '#browse'
}];
- if (parentTopic) {
+ if (options.parentTopic) {
breadcrumbs.push({
- title: parentTopic.get('name'),
- url: '#topics/' + parentTopic.id
+ title: options.parentTopic.get('name'),
+ url: '#topics/' + options.parentTopic.id
});
}
headerView = new HeaderView({
model: new TeamsHeaderModel({
- description: subject.get('description'),
- title: subject.get('name'),
+ description: options.subject.get('description'),
+ title: options.subject.get('name'),
breadcrumbs: breadcrumbs
}),
+ headerActionsView: options.headerActionsView,
events: {
'click nav.breadcrumbs a.nav-item': function (event) {
var url = $(event.currentTarget).attr('href');
@@ -314,7 +341,7 @@
});
return new ViewWithHeader({
header: headerView,
- main: mainView
+ main: options.mainView
});
},
diff --git a/lms/djangoapps/teams/static/teams/templates/team-join.underscore b/lms/djangoapps/teams/static/teams/templates/team-join.underscore
new file mode 100644
index 0000000000..c6379f5bf3
--- /dev/null
+++ b/lms/djangoapps/teams/static/teams/templates/team-join.underscore
@@ -0,0 +1,9 @@
+
diff --git a/lms/static/js/components/header/views/header.js b/lms/static/js/components/header/views/header.js
index 34a2da25d3..73910aefb1 100644
--- a/lms/static/js/components/header/views/header.js
+++ b/lms/static/js/components/header/views/header.js
@@ -8,6 +8,7 @@
var HeaderView = Backbone.View.extend({
initialize: function (options) {
this.template = _.template(headerTemplate);
+ this.headerActionsView = options.headerActionsView;
this.listenTo(this.model, 'change', this.render);
this.render();
},
@@ -15,6 +16,9 @@
render: function () {
var json = this.model.attributes;
this.$el.html(this.template(json));
+ if (this.headerActionsView) {
+ this.headerActionsView.setElement(this.$('.header-action-view')).render();
+ }
return this;
}
});
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js
index 35080e1499..8e21940555 100644
--- a/lms/static/js/spec/main.js
+++ b/lms/static/js/spec/main.js
@@ -800,7 +800,8 @@
'lms/include/teams/js/spec/views/teams_tab_spec.js',
'lms/include/teams/js/spec/views/topic_card_spec.js',
'lms/include/teams/js/spec/views/topic_teams_spec.js',
- 'lms/include/teams/js/spec/views/topics_spec.js'
+ 'lms/include/teams/js/spec/views/topics_spec.js',
+ 'lms/include/teams/js/spec/views/team_join_spec.js'
]);
}).call(this, requirejs, define);
diff --git a/lms/static/sass/views/_teams.scss b/lms/static/sass/views/_teams.scss
index 74e5a1824e..00abcf995a 100644
--- a/lms/static/sass/views/_teams.scss
+++ b/lms/static/sass/views/_teams.scss
@@ -228,7 +228,7 @@
.action-view {
@extend %btn-pl-default-base;
- float: right;
+ @include float(right);
margin: ($baseline/4) 0;
}
}
@@ -242,11 +242,11 @@
.meta-detail {
margin-top: ($baseline/4);
- margin-right: ($baseline*.75);
+ @include margin-right ($baseline*.75);
color: $gray;
.icon {
- margin-right: ($baseline/4);
+ @include margin-right ($baseline/4);
}
}
@@ -306,7 +306,7 @@
}
.team-activity {
- float: right;
+ @include float(right);
}
.card-actions {
@@ -328,7 +328,7 @@
display: block;
position: absolute;
top: ($baseline/2);
- left: -($baseline/4);
+ @include left(-($baseline/4));
box-shadow: 1px 1px 1px 0 $blue-d1;
background-color: $m-blue-l2;
padding: ($baseline/10) ($baseline*.75);
@@ -400,8 +400,8 @@
display: inline-block;
@include tooltip-hover-style(-($baseline*2));
+ }
}
- }
}
.create-team {
@@ -484,7 +484,23 @@
}
}
- .form-actions {
+ .required-wrapper {
+ display: inline-block;
+ vertical-align: top;
+ width: 60%; // TODO: susy grid
+ }
+
+ .optional-wrapper {
+ display: inline-block;
+ vertical-align: top;
+ width: 35%; // TODO: susy grid
+ @include margin-left(2%);
+ border-left: 2px solid $gray-l4;
+ padding-left: 2%;
+ }
+ }
+
+ .form-actions {
margin-top: $baseline;
}
@@ -512,25 +528,24 @@
&:focus {
border: 1px solid $link-color;
color: $link-color;
- }
}
+ }
- .required-wrapper {
- display: inline-block;
- vertical-align: top;
- width: 60%; // TODO: susy grid
- }
+ .header-action-view {
+ display: inline-block;
+ width: 33%;
+ vertical-align: text-bottom;
- .optional-wrapper {
- display: inline-block;
- vertical-align: top;
- width: 35%; // TODO: susy grid
- margin-left: 2%;
- border-left: 2px solid $gray-l4;
- padding-left: 2%;
+ .join-team.form-actions, .join-team-message {
+ @include text-align(right);
}
}
+ .join-team-message {
+ @extend %t-copy-sub1;
+ color: $gray-l1;
+ }
+
.team-actions {
@extend %ui-well;
margin: 20px 1.2%;
@@ -687,3 +702,5 @@
.create-team.form-actions {
margin-top: $baseline;
}
+
+
diff --git a/lms/templates/components/header/header.underscore b/lms/templates/components/header/header.underscore
index 3f989b9558..b8de6f7c75 100644
--- a/lms/templates/components/header/header.underscore
+++ b/lms/templates/components/header/header.underscore
@@ -11,4 +11,5 @@
<%- title %>
<%- description %>
+