From 853f97afbc001d07582a1b8488edd84c8ef4ed8f Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Mon, 10 Aug 2015 16:31:45 +0500 Subject: [PATCH] support joining a team TNL-1909 --- common/test/acceptance/pages/lms/teams.py | 22 +++ .../test/acceptance/tests/lms/test_teams.py | 58 ++++++ .../teams/js/spec/views/team_join_spec.js | 175 ++++++++++++++++++ .../teams/static/teams/js/views/team_join.js | 123 ++++++++++++ .../static/teams/js/views/team_profile.js | 15 +- .../teams/static/teams/js/views/teams_tab.js | 49 +++-- .../teams/templates/team-join.underscore | 9 + .../js/components/header/views/header.js | 4 + lms/static/js/spec/main.js | 3 +- lms/static/sass/views/_teams.scss | 57 ++++-- .../components/header/header.underscore | 1 + 11 files changed, 477 insertions(+), 39 deletions(-) create mode 100644 lms/djangoapps/teams/static/teams/js/spec/views/team_join_spec.js create mode 100644 lms/djangoapps/teams/static/teams/js/views/team_join.js create mode 100644 lms/djangoapps/teams/static/teams/templates/team-join.underscore 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 @@ +
+ <% if (showButton) {%> + + <% } else if (message) { %> +

<%- message %>

+ <% } %> +
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 %>

+