diff --git a/common/test/acceptance/pages/common/utils.py b/common/test/acceptance/pages/common/utils.py new file mode 100644 index 0000000000..8487933f76 --- /dev/null +++ b/common/test/acceptance/pages/common/utils.py @@ -0,0 +1,62 @@ +""" +Utility methods common to Studio and the LMS. +""" +from bok_choy.promise import EmptyPromise +from ...tests.helpers import disable_animations + + +def wait_for_notification(page): + """ + Waits for the "mini-notification" to appear and disappear on the given page (subclass of PageObject). + """ + def _is_saving(): + """Whether or not the notification is currently showing.""" + return page.q(css='.wrapper-notification-mini.is-shown').present + + def _is_saving_done(): + """Whether or not the notification is finished showing.""" + return page.q(css='.wrapper-notification-mini.is-hiding').present + + EmptyPromise(_is_saving, 'Notification should have been shown.', timeout=60).fulfill() + EmptyPromise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill() + + +def click_css(page, css, source_index=0, require_notification=True): + """ + Click the button/link with the given css and index on the specified page (subclass of PageObject). + + Will only consider elements that are displayed and have a height and width greater than zero. + + If require_notification is False (default value is True), the method will return immediately. + Otherwise, it will wait for the "mini-notification" to appear and disappear. + """ + def _is_visible(element): + """Is the given element visible?""" + # Only make the call to size once (instead of once for the height and once for the width) + # because otherwise you will trigger a extra query on a remote element. + return element.is_displayed() and all(size > 0 for size in element.size.itervalues()) + + # Disable all animations for faster testing with more reliable synchronization + disable_animations(page) + # Click on the element in the browser + page.q(css=css).filter(_is_visible).nth(source_index).click() + + if require_notification: + wait_for_notification(page) + + # Some buttons trigger ajax posts + # (e.g. .add-missing-groups-button as configured in split_test_author_view.js) + # so after you click anything wait for the ajax call to finish + page.wait_for_ajax() + + +def confirm_prompt(page, cancel=False, require_notification=None): + """ + Ensures that a modal prompt and confirmation button are visible, then clicks the button. The prompt is canceled iff + cancel is True. + """ + page.wait_for_element_visibility('.prompt', 'Prompt is visible') + confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary') + page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible') + require_notification = (not cancel) if require_notification is None else require_notification + click_css(page, confirmation_button_css, require_notification=require_notification) diff --git a/common/test/acceptance/pages/lms/teams.py b/common/test/acceptance/pages/lms/teams.py index 52c5edae60..9637237f02 100644 --- a/common/test/acceptance/pages/lms/teams.py +++ b/common/test/acceptance/pages/lms/teams.py @@ -6,7 +6,7 @@ Teams pages. from .course_page import CoursePage from .discussion import InlineDiscussionPage from ..common.paging import PaginatedUIMixin -from ...pages.studio.utils import confirm_prompt +from ...pages.common.utils import confirm_prompt from .fields import FieldsMixin @@ -43,7 +43,24 @@ class TeamCardsMixin(object): return self.q(css='p.card-description').map(lambda e: e.text).results -class TeamsPage(CoursePage): +class BreadcrumbsMixin(object): + """Provides common operations on teams page breadcrumb links.""" + + @property + def header_page_breadcrumbs(self): + """Get the page breadcrumb text displayed by the page header""" + return self.q(css='.page-header .breadcrumbs')[0].text + + def click_all_topics(self): + """ Click on the "All Topics" breadcrumb """ + self.q(css='a.nav-item').filter(text='All Topics')[0].click() + + def click_specific_topic(self, topic): + """ Click on the breadcrumb for a specific topic """ + self.q(css='a.nav-item').filter(text=topic)[0].click() + + +class TeamsPage(CoursePage, BreadcrumbsMixin): """ Teams page/tab. """ @@ -92,7 +109,7 @@ class TeamsPage(CoursePage): # Click to "My Team" and verify that it contains the expected number of teams. self.q(css=MY_TEAMS_BUTTON_CSS).click() - + self.wait_for_ajax() self.wait_for( lambda: len(self.q(css='.team-card')) == expected_count, description="Expected number of teams is wrong" @@ -173,7 +190,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): self.wait_for_ajax() -class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): +class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin, BreadcrumbsMixin): """ The paginated UI for browsing teams within a Topic on the Teams page. @@ -211,6 +228,11 @@ class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): lambda e: e.is_selected() ).results[0].text.strip() + @property + def team_names(self): + """Get all the team names on the page.""" + return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results + def click_create_team_link(self): """ Click on create team link.""" query = self.q(css=CREATE_TEAM_LINK_CSS) @@ -282,9 +304,9 @@ class SearchTeamsPage(BaseTeamsPage): self.url_path = "teams/#topics/{topic_id}/search".format(topic_id=self.topic['id']) -class CreateOrEditTeamPage(CoursePage, FieldsMixin): +class TeamManagementPage(CoursePage, FieldsMixin, BreadcrumbsMixin): """ - Create team page. + Team page for creation, editing, and deletion. """ def __init__(self, browser, course_id, topic): """ @@ -293,15 +315,13 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): representation of a topic following the same convention as a course module's topic. """ - super(CreateOrEditTeamPage, self).__init__(browser, course_id) + super(TeamManagementPage, self).__init__(browser, course_id) self.topic = topic self.url_path = "teams/#topics/{topic_id}/create-team".format(topic_id=self.topic['id']) def is_browser_on_page(self): """Check if we're on the create team page for a particular topic.""" - has_correct_url = self.url.endswith(self.url_path) - teams_create_view_present = self.q(css='.team-edit-fields').present - return has_correct_url and teams_create_view_present + return self.q(css='.team-edit-fields').present @property def header_page_name(self): @@ -313,11 +333,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): """Get the page description displayed by the page header""" return self.q(css='.page-header .page-description')[0].text - @property - def header_page_breadcrumbs(self): - """Get the page breadcrumb text displayed by the page header""" - return self.q(css='.page-header .breadcrumbs')[0].text - @property def validation_message_text(self): """Get the error message text""" @@ -333,8 +348,70 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): self.q(css='.create-team .action-cancel').first.click() self.wait_for_ajax() + @property + def delete_team_button(self): + """Returns the 'delete team' button.""" + return self.q(css='.action-delete').first -class TeamPage(CoursePage, PaginatedUIMixin): + def click_membership_button(self): + """Clicks the 'edit membership' button""" + self.q(css='.action-edit-members').first.click() + self.wait_for_ajax() + + @property + def membership_button_present(self): + """Checks if the edit membership button is present""" + return self.q(css='.action-edit-members').present + + +class EditMembershipPage(CoursePage): + """ + Staff or discussion-privileged user page to remove troublesome or inactive + students from a team + """ + def __init__(self, browser, course_id, team): + """ + Set up `self.url_path` on instantiation, since it dynamically + reflects the current team. + """ + super(EditMembershipPage, self).__init__(browser, course_id) + self.team = team + self.url_path = "teams/#teams/{topic_id}/{team_id}/edit-team/manage-members".format( + topic_id=self.team['topic_id'], team_id=self.team['id'] + ) + + def is_browser_on_page(self): + """Check if we're on the team membership page for a particular team.""" + self.wait_for_ajax() + + if self.q(css='.edit-members').present: + return True + empty_query = self.q(css='.teams-main>.page-content>p').first + return ( + len(empty_query.results) > 0 and + empty_query[0].text == "This team does not have any members." + ) + + @property + def team_members(self): + """Returns the number of team members shown on the page.""" + return len(self.q(css='.team-member')) + + def click_first_remove(self): + """Clicks the remove link on the first member listed.""" + self.q(css='.action-remove-member').first.click() + + def confirm_delete_membership_dialog(self): + """Click 'delete' on the warning dialog.""" + confirm_prompt(self, require_notification=False) + self.wait_for_ajax() + + def cancel_delete_membership_dialog(self): + """Click 'delete' on the warning dialog.""" + confirm_prompt(self, cancel=True) + + +class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin): """ The page for a specific Team within the Teams tab """ @@ -483,11 +560,6 @@ class TeamPage(CoursePage, PaginatedUIMixin): """ Returns True if New Post button is present else False """ return self.q(css='.discussion-module .new-post-btn').present - def click_all_topics_breadcrumb(self): - """Navigate to the 'All Topics' page.""" - self.q(css='.breadcrumbs a').results[0].click() - self.wait_for_ajax() - @property def edit_team_button_present(self): """ Returns True if Edit Team button is present else False """ diff --git a/common/test/acceptance/pages/studio/component_editor.py b/common/test/acceptance/pages/studio/component_editor.py index 2448e84eb8..0565163dbd 100644 --- a/common/test/acceptance/pages/studio/component_editor.py +++ b/common/test/acceptance/pages/studio/component_editor.py @@ -1,6 +1,6 @@ from bok_choy.page_object import PageObject from selenium.webdriver.common.keys import Keys -from utils import click_css +from ..common.utils import click_css from selenium.webdriver.support.ui import Select diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 9966eeff4f..97c1962742 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -6,7 +6,9 @@ from bok_choy.page_object import PageObject from bok_choy.promise import Promise, EmptyPromise from . import BASE_URL -from .utils import click_css, confirm_prompt, type_in_codemirror +from ..common.utils import click_css, confirm_prompt + +from .utils import type_in_codemirror class ContainerPage(PageObject): diff --git a/common/test/acceptance/pages/studio/import_export.py b/common/test/acceptance/pages/studio/import_export.py index 91108ede2b..dfebc0229b 100644 --- a/common/test/acceptance/pages/studio/import_export.py +++ b/common/test/acceptance/pages/studio/import_export.py @@ -9,7 +9,8 @@ import os import re import requests -from .utils import click_css +from ..common.utils import click_css + from .library import LibraryPage from .course_page import CoursePage from . import BASE_URL diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index 2163a9187c..ad60c7dc2a 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -9,7 +9,9 @@ from .component_editor import ComponentEditorView from .container import XBlockWrapper from ...pages.studio.users import UsersPageMixin from ...pages.studio.pagination import PaginatedMixin -from .utils import confirm_prompt, wait_for_notification + +from ..common.utils import confirm_prompt, wait_for_notification + from . import BASE_URL diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index bc55a408ce..1b9c25683a 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -9,9 +9,11 @@ from bok_choy.promise import EmptyPromise from selenium.webdriver.support.ui import Select from selenium.webdriver.common.keys import Keys +from ..common.utils import click_css, confirm_prompt + from .course_page import CoursePage from .container import ContainerPage -from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt +from .utils import set_input_value_and_save, set_input_value class CourseOutlineItem(object): diff --git a/common/test/acceptance/pages/studio/settings_group_configurations.py b/common/test/acceptance/pages/studio/settings_group_configurations.py index 95159c369a..7a77db5e68 100644 --- a/common/test/acceptance/pages/studio/settings_group_configurations.py +++ b/common/test/acceptance/pages/studio/settings_group_configurations.py @@ -2,8 +2,8 @@ Course Group Configurations page. """ from bok_choy.promise import EmptyPromise +from ..common.utils import confirm_prompt from .course_page import CoursePage -from .utils import confirm_prompt class GroupConfigurationsPage(CoursePage): diff --git a/common/test/acceptance/pages/studio/textbooks.py b/common/test/acceptance/pages/studio/textbooks.py index 15f5519f6e..76160a4dbd 100644 --- a/common/test/acceptance/pages/studio/textbooks.py +++ b/common/test/acceptance/pages/studio/textbooks.py @@ -4,8 +4,8 @@ Course Textbooks page. import requests from path import Path as path +from ..common.utils import click_css from .course_page import CoursePage -from .utils import click_css class TextbooksPage(CoursePage): diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py index dbcea8c6b5..cd2607af35 100644 --- a/common/test/acceptance/pages/studio/utils.py +++ b/common/test/acceptance/pages/studio/utils.py @@ -3,52 +3,9 @@ Utility methods useful for Studio page tests. """ from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys -from bok_choy.promise import EmptyPromise from bok_choy.javascript import js_defined -from ...tests.helpers import disable_animations - - -def click_css(page, css, source_index=0, require_notification=True): - """ - Click the button/link with the given css and index on the specified page (subclass of PageObject). - - Will only consider elements that are displayed and have a height and width greater than zero. - - If require_notification is False (default value is True), the method will return immediately. - Otherwise, it will wait for the "mini-notification" to appear and disappear. - """ - def _is_visible(el): - # Only make the call to size once (instead of once for the height and once for the width) - # because otherwise you will trigger a extra query on a remote element. - return el.is_displayed() and all(size > 0 for size in el.size.itervalues()) - - # Disable all animations for faster testing with more reliable synchronization - disable_animations(page) - # Click on the element in the browser - page.q(css=css).filter(lambda el: _is_visible(el)).nth(source_index).click() - - if require_notification: - wait_for_notification(page) - - # Some buttons trigger ajax posts - # (e.g. .add-missing-groups-button as configured in split_test_author_view.js) - # so after you click anything wait for the ajax call to finish - page.wait_for_ajax() - - -def wait_for_notification(page): - """ - Waits for the "mini-notification" to appear and disappear on the given page (subclass of PageObject). - """ - def _is_saving(): - return page.q(css='.wrapper-notification-mini.is-shown').present - - def _is_saving_done(): - return page.q(css='.wrapper-notification-mini.is-hiding').present - - EmptyPromise(_is_saving, 'Notification should have been shown.', timeout=60).fulfill() - EmptyPromise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill() +from ..common.utils import click_css, wait_for_notification @js_defined('window.jQuery') @@ -177,18 +134,6 @@ def get_codemirror_value(page, index=0, find_prefix="$"): ) -def confirm_prompt(page, cancel=False, require_notification=None): - """ - Ensures that a modal prompt and confirmation button are visible, then clicks the button. The prompt is canceled iff - cancel is True. - """ - page.wait_for_element_visibility('.prompt', 'Prompt is visible') - confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary') - page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible') - require_notification = (not cancel) if require_notification is None else require_notification - click_css(page, confirmation_button_css, require_notification=require_notification) - - def set_input_value(page, css, value): """ Sets the text field with the given label (display name) to the specified value. diff --git a/common/test/acceptance/pages/studio/video/video.py b/common/test/acceptance/pages/studio/video/video.py index b35b620f92..3c4ae03559 100644 --- a/common/test/acceptance/pages/studio/video/video.py +++ b/common/test/acceptance/pages/studio/video/video.py @@ -8,8 +8,8 @@ from bok_choy.promise import EmptyPromise, Promise from bok_choy.javascript import wait_for_js, js_defined from ....tests.helpers import YouTubeStubConfig from ...lms.video.video import VideoPage +from ...common.utils import wait_for_notification from selenium.webdriver.common.keys import Keys -from ..utils import wait_for_notification CLASS_SELECTORS = { diff --git a/common/test/acceptance/tests/lms/test_lms_courseware_search.py b/common/test/acceptance/tests/lms/test_lms_courseware_search.py index 8b94b185f7..bc29f3cda1 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware_search.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware_search.py @@ -7,7 +7,8 @@ from nose.plugins.attrib import attr from ..helpers import UniqueCourseTest, remove_file from ...pages.common.logout import LogoutPage -from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror +from ...pages.common.utils import click_css +from ...pages.studio.utils import add_html_component, type_in_codemirror from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.container import ContainerPage diff --git a/common/test/acceptance/tests/lms/test_lms_dashboard_search.py b/common/test/acceptance/tests/lms/test_lms_dashboard_search.py index a549b4bfe0..fa13aeb8aa 100644 --- a/common/test/acceptance/tests/lms/test_lms_dashboard_search.py +++ b/common/test/acceptance/tests/lms/test_lms_dashboard_search.py @@ -7,7 +7,8 @@ import json from bok_choy.web_app_test import WebAppTest from ..helpers import generate_course_key from ...pages.common.logout import LogoutPage -from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror +from ...pages.common.utils import click_css +from ...pages.studio.utils import add_html_component, type_in_codemirror from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.container import ContainerPage diff --git a/common/test/acceptance/tests/lms/test_teams.py b/common/test/acceptance/tests/lms/test_teams.py index d0f2c367b8..950e2c8337 100644 --- a/common/test/acceptance/tests/lms/test_teams.py +++ b/common/test/acceptance/tests/lms/test_teams.py @@ -22,7 +22,16 @@ from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.course_info import CourseInfoPage from ...pages.lms.learner_profile import LearnerProfilePage from ...pages.lms.tab_nav import TabNavPage -from ...pages.lms.teams import TeamsPage, MyTeamsPage, BrowseTopicsPage, BrowseTeamsPage, CreateOrEditTeamPage, TeamPage +from ...pages.lms.teams import ( + TeamsPage, + MyTeamsPage, + BrowseTopicsPage, + BrowseTeamsPage, + TeamManagementPage, + EditMembershipPage, + TeamPage +) +from ...pages.common.utils import confirm_prompt TOPICS_PER_PAGE = 12 @@ -199,7 +208,7 @@ class TeamsTabTest(TeamsTabBase): @ddt.data( 'topics/{topic_id}', 'topics/{topic_id}/search', - 'topics/{topic_id}/{team_id}/edit-team', + 'teams/{topic_id}/{team_id}/edit-team', 'teams/{topic_id}/{team_id}' ) def test_unauthorized_error_message(self, route): @@ -209,10 +218,10 @@ class TeamsTabTest(TeamsTabBase): """ topics = self.create_topics(1) topic = topics[0] - self.set_team_configuration({ - u'max_team_size': 10, - u'topics': topics - }) + self.set_team_configuration( + {u'max_team_size': 10, u'topics': topics}, + global_staff=True + ) team = self.create_teams(topic, 1)[0] self.teams_page.visit() self.browser.delete_cookie('sessionid') @@ -384,7 +393,7 @@ class BrowseTopicsTest(TeamsTabBase): browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic) self.assertTrue(browse_teams_page.is_browser_on_page()) browse_teams_page.click_create_team_link() - create_team_page = CreateOrEditTeamPage(self.browser, self.course_id, topic) + create_team_page = TeamManagementPage(self.browser, self.course_id, topic) create_team_page.value_for_text_field(field_id='name', value='Team Name', press_enter=False) create_team_page.value_for_textarea_field( field_id='description', @@ -393,8 +402,9 @@ class BrowseTopicsTest(TeamsTabBase): create_team_page.submit_form() team_page = TeamPage(self.browser, self.course_id) self.assertTrue(team_page.is_browser_on_page) - team_page.click_all_topics_breadcrumb() + team_page.click_all_topics() self.assertTrue(self.topics_page.is_browser_on_page()) + self.topics_page.wait_for_ajax() self.assertEqual(topic_name, self.topics_page.topic_names[0]) def test_list_topics(self): @@ -834,21 +844,25 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase): @attr('shard_5') class TeamFormActions(TeamsTabBase): """ - Base class for create & edit team. + Base class for create, edit, and delete team. """ TEAM_DESCRIPTION = 'The Avengers are a fictional team of superheroes.' topic = {'name': 'Example Topic', 'id': 'example_topic', 'description': 'Description'} TEAMS_NAME = 'Avengers' + def setUp(self): + super(TeamFormActions, self).setUp() + self.team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic) + def verify_page_header(self, title, description, breadcrumbs): """ Verify that the page header correctly reflects the create team header, description and breadcrumb. """ - self.assertEqual(self.create_or_edit_team_page.header_page_name, title) - self.assertEqual(self.create_or_edit_team_page.header_page_description, description) - self.assertEqual(self.create_or_edit_team_page.header_page_breadcrumbs, breadcrumbs) + self.assertEqual(self.team_management_page.header_page_name, title) + self.assertEqual(self.team_management_page.header_page_description, description) + self.assertEqual(self.team_management_page.header_page_breadcrumbs, breadcrumbs) def verify_and_navigate_to_create_team_page(self): """Navigates to the create team page and verifies.""" @@ -868,7 +882,7 @@ class TeamFormActions(TeamsTabBase): self.team_page.click_edit_team_button() - self.create_or_edit_team_page.wait_for_page() + self.team_management_page.wait_for_page() # Edit page header. self.verify_page_header( @@ -891,33 +905,37 @@ class TeamFormActions(TeamsTabBase): def fill_create_or_edit_form(self): """Fill the create/edit team form fields with appropriate values.""" - self.create_or_edit_team_page.value_for_text_field(field_id='name', value=self.TEAMS_NAME, press_enter=False) - self.create_or_edit_team_page.value_for_textarea_field( + self.team_management_page.value_for_text_field( + field_id='name', + value=self.TEAMS_NAME, + press_enter=False + ) + self.team_management_page.value_for_textarea_field( field_id='description', value=self.TEAM_DESCRIPTION ) - self.create_or_edit_team_page.value_for_dropdown_field(field_id='language', value='English') - self.create_or_edit_team_page.value_for_dropdown_field(field_id='country', value='Pakistan') + self.team_management_page.value_for_dropdown_field(field_id='language', value='English') + self.team_management_page.value_for_dropdown_field(field_id='country', value='Pakistan') def verify_all_fields_exist(self): """ Verify the fields for create/edit page. """ self.assertEqual( - self.create_or_edit_team_page.message_for_field('name'), + self.team_management_page.message_for_field('name'), 'A name that identifies your team (maximum 255 characters).' ) self.assertEqual( - self.create_or_edit_team_page.message_for_textarea_field('description'), + self.team_management_page.message_for_textarea_field('description'), 'A short description of the team to help other learners understand ' 'the goals or direction of the team (maximum 300 characters).' ) self.assertEqual( - self.create_or_edit_team_page.message_for_field('country'), + self.team_management_page.message_for_field('country'), 'The country that team members primarily identify with.' ) self.assertEqual( - self.create_or_edit_team_page.message_for_field('language'), + self.team_management_page.message_for_field('language'), 'The language that team members primarily use to communicate with each other.' ) @@ -932,7 +950,6 @@ class CreateTeamTest(TeamFormActions): super(CreateTeamTest, self).setUp() self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}) - self.create_or_edit_team_page = CreateOrEditTeamPage(self.browser, self.course_id, self.topic) self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic) self.browse_teams_page.visit() @@ -960,14 +977,14 @@ class CreateTeamTest(TeamFormActions): Then I should see the error message and highlighted fields. """ self.verify_and_navigate_to_create_team_page() - self.create_or_edit_team_page.submit_form() + self.team_management_page.submit_form() self.assertEqual( - self.create_or_edit_team_page.validation_message_text, + self.team_management_page.validation_message_text, 'Check the highlighted fields below and try again.' ) - self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='name')) - self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='description')) + self.assertTrue(self.team_management_page.error_for_field(field_id='name')) + self.assertTrue(self.team_management_page.error_for_field(field_id='description')) def test_user_can_see_error_message_for_incorrect_data(self): """ @@ -982,7 +999,7 @@ class CreateTeamTest(TeamFormActions): self.verify_and_navigate_to_create_team_page() # Fill the name field with >255 characters to see validation message. - self.create_or_edit_team_page.value_for_text_field( + self.team_management_page.value_for_text_field( field_id='name', value='EdX is a massive open online course (MOOC) provider and online learning platform. ' 'It hosts online university-level courses in a wide range of disciplines to a worldwide ' @@ -994,13 +1011,13 @@ class CreateTeamTest(TeamFormActions): 'edX has more than 4 million users taking more than 500 courses online.', press_enter=False ) - self.create_or_edit_team_page.submit_form() + self.team_management_page.submit_form() self.assertEqual( - self.create_or_edit_team_page.validation_message_text, + self.team_management_page.validation_message_text, 'Check the highlighted fields below and try again.' ) - self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='name')) + self.assertTrue(self.team_management_page.error_for_field(field_id='name')) def test_user_can_create_new_team_successfully(self): """ @@ -1040,7 +1057,7 @@ class CreateTeamTest(TeamFormActions): } ] with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events): - self.create_or_edit_team_page.submit_form() + self.team_management_page.submit_form() # Verify that the page is shown for the new team team_page = TeamPage(self.browser, self.course_id) @@ -1072,7 +1089,7 @@ class CreateTeamTest(TeamFormActions): self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total')) self.verify_and_navigate_to_create_team_page() - self.create_or_edit_team_page.cancel_team() + self.team_management_page.cancel_team() self.assertTrue(self.browse_teams_page.is_browser_on_page()) self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total')) @@ -1101,6 +1118,131 @@ class CreateTeamTest(TeamFormActions): self.verify_and_navigate_to_create_team_page() +@ddt.ddt +class DeleteTeamTest(TeamFormActions): + """ + Tests for deleting teams. + """ + + def setUp(self): + super(DeleteTeamTest, self).setUp() + + self.set_team_configuration( + {'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}, + global_staff=True + ) + + self.team = self.create_teams(self.topic, num_teams=1)[0] + self.team_page = TeamPage(self.browser, self.course_id, team=self.team) + + #need to have a membership to confirm it gets deleted as well + self.create_membership(self.user_info['username'], self.team['id']) + + self.team_page.visit() + + def test_cancel_delete(self): + """ + Scenario: The user should be able to cancel the Delete Team dialog + Given I am staff user for a course with a team + When I visit the Team profile page + Then I should see the Edit Team button + And When I click edit team button + Then I should see the Delete Team button + When I click the delete team button + And I cancel the prompt + And I refresh the page + Then I should still see the team + """ + self.delete_team(cancel=True) + self.assertTrue(self.team_management_page.is_browser_on_page()) + self.browser.refresh() + self.team_management_page.wait_for_page() + self.assertEqual( + ' '.join(('All Topics', self.topic['name'], self.team['name'])), + self.team_management_page.header_page_breadcrumbs + ) + + @ddt.data('Moderator', 'Community TA', 'Administrator', None) + def test_delete_team(self, role): + """ + Scenario: The user should be able to see and navigate to the delete team page. + Given I am staff user for a course with a team + When I visit the Team profile page + Then I should see the Edit Team button + And When I click edit team button + Then I should see the Delete Team button + When I click the delete team button + And I confirm the prompt + Then I should see the browse teams page + And the team should not be present + """ + # If role is None, remain logged in as global staff + if role is not None: + AutoAuthPage( + self.browser, + course_id=self.course_id, + staff=False, + roles=role + ).visit() + self.team_page.visit() + self.delete_team(require_notification=False) + browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic) + self.assertTrue(browse_teams_page.is_browser_on_page()) + self.assertNotIn(self.team['name'], browse_teams_page.team_names) + + def delete_team(self, **kwargs): + """ + Delete a team. Passes `kwargs` to `confirm_prompt`. + Expects edx.team.deleted event to be emitted, with correct course_id. + Also expects edx.team.learner_removed event to be emitted for the + membership that is removed as a part of the delete operation. + """ + + self.team_page.click_edit_team_button() + self.team_management_page.wait_for_page() + self.team_management_page.delete_team_button.click() + + if 'cancel' in kwargs and kwargs['cancel'] is True: + confirm_prompt(self.team_management_page, **kwargs) + else: + expected_events = [ + { + 'event_type': 'edx.team.deleted', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'] + } + }, + { + 'event_type': 'edx.team.learner_removed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'remove_method': 'team_deleted', + 'user_id': self.user_info['user_id'] + } + } + ] + with self.assert_events_match_during( + event_filter=self.only_team_events, expected_events=expected_events + ): + confirm_prompt(self.team_management_page, **kwargs) + + def test_delete_team_updates_topics(self): + """ + Scenario: Deleting a team should update the team count on the topics page + Given I am staff user for a course with a team + And I delete a team + When I navigate to the browse topics page + Then the team count for the deletd team's topic should be updated + """ + self.delete_team(require_notification=False) + BrowseTeamsPage(self.browser, self.course_id, self.topic).click_all_topics() + topics_page = BrowseTopicsPage(self.browser, self.course_id) + self.assertTrue(topics_page.is_browser_on_page()) + self.teams_page.verify_topic_team_count(0) + + @ddt.ddt class EditTeamTest(TeamFormActions): """ @@ -1114,7 +1256,6 @@ class EditTeamTest(TeamFormActions): {'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}, global_staff=True ) - self.create_or_edit_team_page = CreateOrEditTeamPage(self.browser, self.course_id, self.topic) self.team = self.create_teams(self.topic, num_teams=1)[0] self.team_page = TeamPage(self.browser, self.course_id, team=self.team) @@ -1204,7 +1345,7 @@ class EditTeamTest(TeamFormActions): }, ] with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events): - self.create_or_edit_team_page.submit_form() + self.team_management_page.submit_form() self.team_page.wait_for_page() @@ -1237,7 +1378,7 @@ class EditTeamTest(TeamFormActions): self.verify_and_navigate_to_edit_team_page() self.fill_create_or_edit_form() - self.create_or_edit_team_page.cancel_team() + self.team_management_page.cancel_team() self.team_page.wait_for_page() @@ -1289,7 +1430,7 @@ class EditTeamTest(TeamFormActions): self.verify_and_navigate_to_edit_team_page() self.fill_create_or_edit_form() - self.create_or_edit_team_page.submit_form() + self.team_management_page.submit_form() self.team_page.wait_for_page() @@ -1319,6 +1460,108 @@ class EditTeamTest(TeamFormActions): self.verify_and_navigate_to_edit_team_page() +@ddt.ddt +class EditMembershipTest(TeamFormActions): + """ + Tests for administrating from the team membership page + """ + + def setUp(self): + super(EditMembershipTest, self).setUp() + + self.set_team_configuration( + {'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}, + global_staff=True + ) + self.team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic) + self.team = self.create_teams(self.topic, num_teams=1)[0] + + #make sure a user exists on this team so we can edit the membership + self.create_membership(self.user_info['username'], self.team['id']) + + self.edit_membership_page = EditMembershipPage(self.browser, self.course_id, self.team) + self.team_page = TeamPage(self.browser, self.course_id, team=self.team) + + def edit_membership_helper(self, role, cancel=False): + """ + Helper for common functionality in edit membership tests. + Checks for all relevant assertions about membership being removed, + including verify edx.team.learner_removed events are emitted. + """ + if role is not None: + AutoAuthPage( + self.browser, + course_id=self.course_id, + staff=False, + roles=role + ).visit() + + self.team_page.visit() + self.team_page.click_edit_team_button() + self.team_management_page.wait_for_page() + + self.assertTrue( + self.team_management_page.membership_button_present + ) + + self.team_management_page.click_membership_button() + self.edit_membership_page.wait_for_page() + self.edit_membership_page.click_first_remove() + if cancel: + self.edit_membership_page.cancel_delete_membership_dialog() + self.assertEqual(self.edit_membership_page.team_members, 1) + else: + expected_events = [ + { + 'event_type': 'edx.team.learner_removed', + 'event': { + 'course_id': self.course_id, + 'team_id': self.team['id'], + 'remove_method': 'removed_by_admin', + 'user_id': self.user_info['user_id'] + } + } + ] + with self.assert_events_match_during( + event_filter=self.only_team_events, expected_events=expected_events + ): + self.edit_membership_page.confirm_delete_membership_dialog() + self.assertEqual(self.edit_membership_page.team_members, 0) + self.assertTrue(self.edit_membership_page.is_browser_on_page) + + @ddt.data('Moderator', 'Community TA', 'Administrator', None) + def test_remove_membership(self, role): + """ + Scenario: The user should be able to remove a membership + Given I am staff user for a course with a team + When I visit the Team profile page + Then I should see the Edit Team button + And When I click edit team button + Then I should see the Edit Membership button + And When I click the edit membership button + Then I should see the edit membership page + And When I click the remove button and confirm the dialog + Then my membership should be removed, and I should remain on the page + """ + self.edit_membership_helper(role, cancel=False) + + @ddt.data('Moderator', 'Community TA', 'Administrator', None) + def test_cancel_remove_membership(self, role): + """ + Scenario: The user should be able to remove a membership + Given I am staff user for a course with a team + When I visit the Team profile page + Then I should see the Edit Team button + And When I click edit team button + Then I should see the Edit Membership button + And When I click the edit membership button + Then I should see the edit membership page + And When I click the remove button and cancel the dialog + Then my membership should not be removed, and I should remain on the page + """ + self.edit_membership_helper(role, cancel=True) + + @attr('shard_5') @ddt.ddt class TeamPageTest(TeamsTabBase): diff --git a/lms/djangoapps/teams/search_indexes.py b/lms/djangoapps/teams/search_indexes.py index c29dbb0692..6350f2bf91 100644 --- a/lms/djangoapps/teams/search_indexes.py +++ b/lms/djangoapps/teams/search_indexes.py @@ -4,9 +4,10 @@ import logging from requests import ConnectionError from django.conf import settings -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import translation +from functools import wraps from search.search_engine_base import SearchEngine @@ -14,6 +15,19 @@ from .errors import ElasticSearchConnectionError from .serializers import CourseTeamSerializer, CourseTeam +def if_search_enabled(f): + """ + Only call `f` if search is enabled for the CourseTeamIndexer. + """ + @wraps(f) + def wrapper(*args, **kwargs): + """Wraps the decorated function.""" + cls = args[0] + if cls.search_is_enabled(): + return f(*args, **kwargs) + return wrapper + + class CourseTeamIndexer(object): """ This is the index object for searching and storing CourseTeam model instances. @@ -70,16 +84,25 @@ class CourseTeamIndexer(object): return self.course_team.language @classmethod + @if_search_enabled def index(cls, course_team): """ Update index with course_team object (if feature is enabled). """ - if cls.search_is_enabled(): - search_engine = cls.engine() - serialized_course_team = CourseTeamIndexer(course_team).data() - search_engine.index(cls.DOCUMENT_TYPE_NAME, [serialized_course_team]) + search_engine = cls.engine() + serialized_course_team = CourseTeamIndexer(course_team).data() + search_engine.index(cls.DOCUMENT_TYPE_NAME, [serialized_course_team]) @classmethod + @if_search_enabled + def remove(cls, course_team): + """ + Remove course_team from the index (if feature is enabled). + """ + cls.engine().remove(cls.DOCUMENT_TYPE_NAME, [course_team.team_id]) + + @classmethod + @if_search_enabled def engine(cls): """ Return course team search engine (if feature is enabled). @@ -108,3 +131,11 @@ def course_team_post_save_callback(**kwargs): CourseTeamIndexer.index(kwargs['instance']) except ElasticSearchConnectionError: pass + + +@receiver(post_delete, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_delete_callback') +def course_team_post_delete_callback(**kwargs): # pylint: disable=invalid-name + """ + Reindex object after delete. + """ + CourseTeamIndexer.remove(kwargs['instance']) diff --git a/lms/djangoapps/teams/static/teams/js/collections/topic.js b/lms/djangoapps/teams/static/teams/js/collections/topic.js index b88392a838..581c6b7584 100644 --- a/lms/djangoapps/teams/static/teams/js/collections/topic.js +++ b/lms/djangoapps/teams/static/teams/js/collections/topic.js @@ -25,7 +25,7 @@ }, onUpdate: function(event) { - if (event.action === 'create') { + if (_.contains(['create', 'delete'], event.action)) { this.isStale = true; } }, diff --git a/lms/djangoapps/teams/static/teams/js/spec/collections/topic_collection_spec.js b/lms/djangoapps/teams/static/teams/js/spec/collections/topic_collection_spec.js index da43b583d6..241ef6bf03 100644 --- a/lms/djangoapps/teams/static/teams/js/spec/collections/topic_collection_spec.js +++ b/lms/djangoapps/teams/static/teams/js/spec/collections/topic_collection_spec.js @@ -35,5 +35,14 @@ define(['backbone', 'URI', 'underscore', 'common/js/spec_helpers/ajax_helpers', topicCollection.course_id = 'my+course+id'; testRequestParam(this, 'course_id', 'my+course+id'); }); + + it('sets itself to stale on receiving a teams create or delete event', function () { + expect(topicCollection.isStale).toBe(false); + TeamSpecHelpers.triggerTeamEvent('create'); + expect(topicCollection.isStale).toBe(true); + topicCollection.isStale = false; + TeamSpecHelpers.triggerTeamEvent('delete'); + expect(topicCollection.isStale).toBe(true); + }); }); }); diff --git a/lms/djangoapps/teams/static/teams/js/spec/views/edit_team_members_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/edit_team_members_spec.js new file mode 100644 index 0000000000..aa241b6b5a --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/views/edit_team_members_spec.js @@ -0,0 +1,146 @@ +define([ + 'jquery', + 'underscore', + 'backbone', + 'common/js/spec_helpers/ajax_helpers', + 'teams/js/views/edit_team_members', + 'teams/js/models/team', + 'teams/js/views/team_utils', + 'teams/js/spec_helpers/team_spec_helpers' +], function ($, _, Backbone, AjaxHelpers, TeamEditMembershipView, TeamModel, TeamUtils, TeamSpecHelpers) { + 'use strict'; + + describe('CreateEditTeam', function() { + var editTeamID = 'av', + DEFAULT_MEMBERSHIP = [ + { + 'user': { + 'username': 'frodo', + 'profile_image': { + 'has_image': true, + 'image_url_medium': '/frodo-image-url' + }, + }, + last_activity_at: "2015-08-21T18:53:01.145Z", + date_joined: "2014-01-01T18:53:01.145Z" + } + ], + deleteTeamMemember = function (view, confirm) { + view.$('.action-remove-member').click(); + // Confirm delete dialog + if (confirm) { + $('.action-primary').click(); + } + else { + $('.action-secondary').click(); + } + }, + verifyTeamMembersView = function (view) { + expect(view.$('.team-member').length).toEqual(1); + expect(view.$('.member-profile').attr('href')).toEqual('/u/frodo'); + expect(view.$('img.image-url').attr('src')).toEqual('/frodo-image-url'); + expect(view.$('.member-info-container .primary').text()).toBe('frodo'); + expect(view.$el.find('#last-active abbr').attr('title')).toEqual("2015-08-21T18:53:01.145Z"); + expect(view.$el.find('#date-joined abbr').attr('title')).toEqual("2014-01-01T18:53:01.145Z"); + }, + verifyNoMembersView = function (view){ + expect(view.$el.text().trim()).toBe('This team does not have any members.'); + }, + createTeamModelData = function (membership) { + return { + id: editTeamID, + name: 'Avengers', + description: 'Team of dumbs', + language: 'en', + country: 'US', + membership: membership, + url: '/api/team/v0/teams/' + editTeamID + }; + }, + createEditTeamMembersView = function (membership) { + var teamModel = new TeamModel( + createTeamModelData(membership), + { parse: true } + ); + + return new TeamEditMembershipView({ + teamEvents: TeamSpecHelpers.teamEvents, + el: $('.teams-content'), + model: teamModel, + context: TeamSpecHelpers.testContext + }).render(); + }; + + beforeEach(function () { + setFixtures('
'); + spyOn(Backbone.history, 'navigate'); + spyOn(TeamUtils, 'showMessage'); + }); + + it('can render a message when there are no members', function () { + var view = createEditTeamMembersView([]); + verifyNoMembersView(view); + }); + + it('can delete a team member and update the view', function () { + var requests = AjaxHelpers.requests(this), + view = createEditTeamMembersView(DEFAULT_MEMBERSHIP); + + spyOn(view.teamEvents, 'trigger'); + verifyTeamMembersView(view); + + deleteTeamMemember(view, true); + AjaxHelpers.expectJsonRequest( + requests, + 'DELETE', + '/api/team/v0/team_membership/av,frodo?admin=true' + ); + AjaxHelpers.respondWithNoContent(requests); + expect(view.teamEvents.trigger).toHaveBeenCalledWith( + 'teams:update', { + action: 'leave', + team: view.model + } + ); + AjaxHelpers.expectJsonRequest(requests, 'GET', view.model.get('url')); + AjaxHelpers.respondWithJson(requests, createTeamModelData([])); + + verifyNoMembersView(view); + }); + + it('can show an error message if removing the user fails', function () { + var requests = AjaxHelpers.requests(this), + view = createEditTeamMembersView(DEFAULT_MEMBERSHIP); + + spyOn(view.teamEvents, 'trigger'); + verifyTeamMembersView(view); + + deleteTeamMemember(view, true); + AjaxHelpers.expectJsonRequest( + requests, + 'DELETE', + '/api/team/v0/team_membership/av,frodo?admin=true' + ); + AjaxHelpers.respondWithError(requests); + expect(TeamUtils.showMessage).toHaveBeenCalledWith( + 'An error occurred while removing the member from the team. Try again.', + undefined + ); + expect(view.teamEvents.trigger).not.toHaveBeenCalled(); + verifyTeamMembersView(view); + }); + + it('can cancel team membership deletion', function () { + var requests = AjaxHelpers.requests(this); + var view = createEditTeamMembersView(DEFAULT_MEMBERSHIP); + + spyOn(view.teamEvents, 'trigger'); + verifyTeamMembersView(view); + + deleteTeamMemember(view, false); + expect(requests.length).toBe(0); + expect(view.teamEvents.trigger).not.toHaveBeenCalled(); + verifyTeamMembersView(view); + }); + }); +}); diff --git a/lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js new file mode 100644 index 0000000000..6ae92dd048 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js @@ -0,0 +1,92 @@ +define([ + 'jquery', + 'backbone', + 'underscore', + 'teams/js/models/team', + 'teams/js/views/instructor_tools', + 'teams/js/views/team_utils', + 'teams/js/spec_helpers/team_spec_helpers', + 'common/js/spec_helpers/ajax_helpers' +], function ($, Backbone, _, Team, InstructorToolsView, TeamUtils, TeamSpecHelpers, AjaxHelpers) { + 'use strict'; + + describe('Instructor Tools', function () { + var view, + createInstructorTools = function () { + return new InstructorToolsView({ + team: new Team(TeamSpecHelpers.createMockTeamData(1, 1)[0]), + teamEvents: TeamSpecHelpers.teamEvents + }); + }, + deleteTeam = function (view, confirm) { + view.$('.action-delete').click(); + // Confirm delete dialog + if (confirm) { + $('.action-primary').click(); + } + else { + $('.action-secondary').click(); + } + }, + expectSuccessMessage = function (team) { + expect(TeamUtils.showMessage).toHaveBeenCalledWith( + 'Team "' + team.get('name') + '" successfully deleted.', + 'success' + ); + }; + + beforeEach(function () { + setFixtures('
'); + spyOn(Backbone.history, 'navigate'); + spyOn(TeamUtils, 'showMessage'); + view = createInstructorTools().render(); + spyOn(view.teamEvents, 'trigger'); + }); + + it('can render itself', function () { + expect(_.strip(view.$('.action-delete').text())).toEqual('Delete Team'); + expect(_.strip(view.$('.action-edit-members').text())).toEqual('Edit Membership'); + expect(view.$el.text()).toContain('Instructor tools'); + }); + + it('can delete a team and shows a success message', function () { + var requests = AjaxHelpers.requests(this); + deleteTeam(view, true); + AjaxHelpers.expectJsonRequest(requests, 'DELETE', view.team.url, null); + AjaxHelpers.respondWithNoContent(requests); + expect(Backbone.history.navigate).toHaveBeenCalledWith( + 'topics/' + view.team.get('topic_id'), + {trigger: true} + ); + expect(view.teamEvents.trigger).toHaveBeenCalledWith( + 'teams:update', { + action: 'delete', + team: view.team + } + ); + expectSuccessMessage(view.team); + }); + + it('can cancel team deletion', function () { + var requests = AjaxHelpers.requests(this); + deleteTeam(view, false); + expect(requests.length).toBe(0); + expect(Backbone.history.navigate).not.toHaveBeenCalled(); + }); + + it('shows a success message after receiving a 404', function () { + var requests = AjaxHelpers.requests(this); + deleteTeam(view, true); + AjaxHelpers.respondWithError(requests, 404); + expectSuccessMessage(view.team); + }); + + it('can trigger the edit membership view', function () { + view.$('.action-edit-members').click(); + expect(Backbone.history.navigate).toHaveBeenCalledWith( + 'teams/' + view.team.get('topic_id') + "/" + view.team.id + "/edit-team/manage-members", + {trigger: true} + ); + }); + }); +}); diff --git a/lms/djangoapps/teams/static/teams/js/spec/views/teams_tab_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/teams_tab_spec.js index b7848978aa..cb7f8fc4b9 100644 --- a/lms/djangoapps/teams/static/teams/js/spec/views/teams_tab_spec.js +++ b/lms/djangoapps/teams/static/teams/js/spec/views/teams_tab_spec.js @@ -61,7 +61,7 @@ define([ it('does not interfere with anchor links to #content', function () { var teamsTabView = createTeamsTabView(); teamsTabView.router.navigate('#content', {trigger: true}); - expect(teamsTabView.$('.warning')).toHaveClass('is-hidden'); + expect(teamsTabView.$('.wrapper-msg')).toHaveClass('is-hidden'); }); it('displays and focuses an error message when trying to navigate to a nonexistent page', function () { @@ -156,7 +156,7 @@ define([ } ], 'fires a page view event for the edit team page': [ - 'topics/' + TeamSpecHelpers.testTopicID + '/' + 'test_team_id/edit-team', + 'teams/' + TeamSpecHelpers.testTopicID + '/' + 'test_team_id/edit-team', { page_name: 'edit-team', topic_id: TeamSpecHelpers.testTopicID, @@ -165,7 +165,9 @@ define([ ] }, function (url, expectedEvent) { var requests = AjaxHelpers.requests(this), - teamsTabView = createTeamsTabView(); + teamsTabView = createTeamsTabView({ + userInfo: TeamSpecHelpers.createMockUserInfo({ staff: true }) + }); teamsTabView.router.navigate(url, {trigger: true}); if (requests.length) { AjaxHelpers.respondWithJson(requests, {}); diff --git a/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js b/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js index 2c51ef5ae2..63a2faa92b 100644 --- a/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js +++ b/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js @@ -29,13 +29,16 @@ define([ var createMockTeamData = function (startIndex, stopIndex) { return _.map(_.range(startIndex, stopIndex + 1), function (i) { + var id = "id" + i; return { name: "team " + i, - id: "id " + i, + id: id, language: testLanguages[i%4][0], country: testCountries[i%4][0], membership: [], - last_activity_at: '' + last_activity_at: '', + topic_id: 'topic_id' + i, + url: 'api/team/v0/teams/' + id }; }); }; @@ -124,6 +127,10 @@ define([ }); }; + var triggerTeamEvent = function (action) { + teamEvents.trigger('teams:update', {action: action}); + }; + createMockPostResponse = function(options) { return _.extend( { @@ -327,6 +334,7 @@ define([ createMockThreadResponse: createMockThreadResponse, createMockTopicData: createMockTopicData, createMockTopicCollection: createMockTopicCollection, + triggerTeamEvent: triggerTeamEvent, verifyCards: verifyCards }; }); diff --git a/lms/djangoapps/teams/static/teams/js/views/edit_team_members.js b/lms/djangoapps/teams/static/teams/js/views/edit_team_members.js new file mode 100644 index 0000000000..dc48ca96e3 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/views/edit_team_members.js @@ -0,0 +1,103 @@ +;(function (define) { + 'use strict'; + + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'teams/js/models/team', + 'teams/js/views/team_utils', + 'common/js/components/utils/view_utils', + 'text!teams/templates/edit-team-member.underscore', + 'text!teams/templates/date.underscore' + ], + function (Backbone, $, _, gettext, TeamModel, TeamUtils, ViewUtils, editTeamMemberTemplate, dateTemplate) { + return Backbone.View.extend({ + dateTemplate: _.template(dateTemplate), + teamMemberTemplate: _.template(editTeamMemberTemplate), + errorMessage: gettext("An error occurred while removing the member from the team. Try again."), + + events: { + 'click .action-remove-member': 'removeMember' + }, + + initialize: function(options) { + this.teamMembershipDetailUrl = options.context.teamMembershipDetailUrl; + // The URL ends with team_id,request_username. We want to replace + // the last occurrence of team_id with the actual team_id, and remove request_username + // as the actual user to be removed from the team will be added on before calling DELETE. + this.teamMembershipDetailUrl = this.teamMembershipDetailUrl.substring( + 0, this.teamMembershipDetailUrl.lastIndexOf('team_id') + ) + this.model.get('id') + ","; + + this.teamEvents = options.teamEvents; + }, + + render: function() { + if (this.model.get('membership').length === 0) { + this.$el.html('

' + gettext('This team does not have any members.') + '

'); + } + else { + this.$el.html(''); + this.renderTeamMembers(); + } + return this; + }, + + renderTeamMembers: function() { + var self = this, dateJoined, lastActivity; + + _.each(this.model.get('membership'), function(membership) { + dateJoined = interpolate( + // Translators: 'date' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago) + gettext("Joined %(date)s"), + {date: self.dateTemplate({date: membership.date_joined})}, + true + ); + + lastActivity = interpolate( + // Translators: 'date' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago) + gettext("Last Activity %(date)s"), + {date: self.dateTemplate({date: membership.last_activity_at})}, + true + ); + + // It is assumed that the team member array is automatically in the order of date joined. + self.$('.edit-members').append(self.teamMemberTemplate({ + imageUrl: membership.user.profile_image.image_url_medium, + username: membership.user.username, + memberProfileUrl: '/u/' + membership.user.username, + dateJoined: dateJoined, + lastActive: lastActivity + })); + }); + this.$('abbr').timeago(); + }, + + removeMember: function (event) { + var self = this, username = $(event.currentTarget).data('username'); + event.preventDefault(); + + ViewUtils.confirmThenRunOperation( + gettext('Remove this team member?'), + gettext('This learner will be removed from the team, allowing another learner to take the available spot.'), + gettext('Remove'), + function () { + $.ajax({ + type: 'DELETE', + url: self.teamMembershipDetailUrl.concat(username, '?admin=true') + }).done(function () { + self.teamEvents.trigger('teams:update', { + action: 'leave', + team: self.model + }); + self.model.fetch().done(function() { self.render(); }); + }).fail(function (data) { + TeamUtils.parseAndShowMessage(data, self.errorMessage); + }); + } + ); + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js b/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js new file mode 100644 index 0000000000..b760b209a1 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js @@ -0,0 +1,76 @@ +;(function (define) { + 'use strict'; + + define(['backbone', + 'underscore', + 'gettext', + 'teams/js/views/team_utils', + 'common/js/components/utils/view_utils', + 'text!teams/templates/instructor-tools.underscore'], + function (Backbone, _, gettext, TeamUtils, ViewUtils, instructorToolbarTemplate) { + return Backbone.View.extend({ + + events: { + 'click .action-delete': 'deleteTeam', + 'click .action-edit-members': 'editMembership' + }, + + initialize: function(options) { + this.template = _.template(instructorToolbarTemplate); + this.team = options.team; + this.teamEvents = options.teamEvents; + }, + + render: function() { + this.$el.html(this.template); + return this; + }, + + deleteTeam: function (event) { + event.preventDefault(); + ViewUtils.confirmThenRunOperation( + gettext('Delete this team?'), + gettext('Deleting a team is permanent and cannot be undone. All members are removed from the team, and team discussions can no longer be accessed.'), + gettext('Delete'), + _.bind(this.handleDelete, this) + ); + }, + + editMembership: function (event) { + event.preventDefault(); + Backbone.history.navigate( + 'teams/' + this.team.get('topic_id') + '/' + this.team.id +'/edit-team/manage-members', + {trigger: true} + ); + }, + + handleDelete: function () { + var self = this, + postDelete = function () { + self.teamEvents.trigger('teams:update', { + action: 'delete', + team: self.team + }); + Backbone.history.navigate('topics/' + self.team.get('topic_id'), {trigger: true}); + TeamUtils.showMessage( + interpolate( + gettext('Team "%(team)s" successfully deleted.'), + {team: self.team.get('name')}, + true + ), + 'success' + ); + }; + this.team.destroy().then(postDelete).fail(function (response) { + // In the 404 case, this team has already been + // deleted by someone else. Since the team was + // successfully deleted anyway, just show a + // success message. + if (response.status === 404) { + postDelete(); + } + }); + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/teams/static/teams/js/views/team_card.js b/lms/djangoapps/teams/static/teams/js/views/team_card.js index c9a9cf32f1..6ee47fbb79 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_card.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_card.js @@ -10,7 +10,7 @@ 'teams/js/views/team_utils', 'text!teams/templates/team-membership-details.underscore', 'text!teams/templates/team-country-language.underscore', - 'text!teams/templates/team-activity.underscore' + 'text!teams/templates/date.underscore' ], function ( $, Backbone, @@ -21,7 +21,7 @@ TeamUtils, teamMembershipDetailsTemplate, teamCountryLanguageTemplate, - teamActivityTemplate + dateTemplate ) { var TeamMembershipView, TeamCountryLanguageView, TeamActivityView, TeamCardView; @@ -70,7 +70,7 @@ TeamActivityView = Backbone.View.extend({ tagName: 'div', className: 'team-activity', - template: _.template(teamActivityTemplate), + template: _.template(dateTemplate), initialize: function (options) { this.date = options.date; diff --git a/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js b/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js index e05783ca71..e083ff521e 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js @@ -2,11 +2,12 @@ 'use strict'; define(['backbone', + 'jquery', 'underscore', 'gettext', 'teams/js/views/team_utils', 'text!teams/templates/team-profile-header-actions.underscore'], - function (Backbone, _, gettext, TeamUtils, teamProfileHeaderActionsTemplate) { + function (Backbone, $, _, gettext, TeamUtils, teamProfileHeaderActionsTemplate) { return Backbone.View.extend({ errorMessage: gettext("An error occurred. Try again."), @@ -56,8 +57,10 @@ return view; }, - joinTeam: function () { + joinTeam: function (event) { var view = this; + + event.preventDefault(); $.ajax({ type: 'POST', url: view.context.teamMembershipsUrl, @@ -117,7 +120,7 @@ editTeam: function (event) { event.preventDefault(); Backbone.history.navigate( - 'topics/' + this.topic.id + '/' + this.model.get('id') +'/edit-team', + 'teams/' + this.topic.id + '/' + this.model.get('id') +'/edit-team', {trigger: true} ); } diff --git a/lms/djangoapps/teams/static/teams/js/views/team_utils.js b/lms/djangoapps/teams/static/teams/js/views/team_utils.js index cdf4014c78..a3747d3ac3 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_utils.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_utils.js @@ -1,8 +1,8 @@ /* Team utility methods*/ ;(function (define) { 'use strict'; - define([ - ], function () { + define(["jquery", "underscore" + ], function ($, _) { return { /** @@ -40,9 +40,16 @@ ); }, - showMessage: function (message) { - var messageElement = $('.teams-content .wrapper-msg'); - messageElement.removeClass('is-hidden'); + hideMessage: function () { + $('#teams-message').addClass('.is-hidden'); + }, + + showMessage: function (message, type) { + var messageElement = $('#teams-message'); + if (_.isUndefined(type)) { + type = 'warning'; + } + messageElement.removeClass('is-hidden').addClass(type); $('.teams-content .msg-content .copy').text(message); messageElement.focus(); }, @@ -50,12 +57,14 @@ /** * Parse `data` and show user message. If parsing fails than show `genericErrorMessage` */ - parseAndShowMessage: function (data, genericErrorMessage) { + parseAndShowMessage: function (data, genericErrorMessage, type) { try { var errors = JSON.parse(data.responseText); - this.showMessage(_.isUndefined(errors.user_message) ? genericErrorMessage : errors.user_message); + this.showMessage( + _.isUndefined(errors.user_message) ? genericErrorMessage : errors.user_message, type + ); } catch (error) { - this.showMessage(genericErrorMessage); + this.showMessage(genericErrorMessage, type); } } }; 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 1eb5514b66..d9fc7a721f 100644 --- a/lms/djangoapps/teams/static/teams/js/views/teams_tab.js +++ b/lms/djangoapps/teams/static/teams/js/views/teams_tab.js @@ -2,6 +2,7 @@ 'use strict'; define(['backbone', + 'jquery', 'underscore', 'gettext', 'common/js/components/views/search_field', @@ -19,13 +20,15 @@ 'teams/js/views/my_teams', 'teams/js/views/topic_teams', 'teams/js/views/edit_team', + 'teams/js/views/edit_team_members', 'teams/js/views/team_profile_header_actions', 'teams/js/views/team_utils', + 'teams/js/views/instructor_tools', 'text!teams/templates/teams_tab.underscore'], - function (Backbone, _, gettext, SearchFieldView, HeaderView, HeaderModel, + function (Backbone, $, _, gettext, SearchFieldView, HeaderView, HeaderModel, TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics, TeamsTabbedView, TopicsView, TeamProfileView, MyTeamsView, TopicTeamsView, TeamEditView, - TeamProfileHeaderActionsView, TeamUtils, teamsTemplate) { + TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) { var TeamsHeaderModel = HeaderModel.extend({ initialize: function () { _.extend(this.defaults, {nav_aria_label: gettext('teams')}); @@ -37,12 +40,16 @@ initialize: function (options) { this.header = options.header; this.main = options.main; + this.instructorTools = options.instructorTools; }, render: function () { this.$el.html(_.template(teamsTemplate)); this.$('p.error').hide(); this.header.setElement(this.$('.teams-header')).render(); + if (this.instructorTools) { + this.instructorTools.setElement(this.$('.teams-instructor-tools-bar')).render(); + } this.main.setElement(this.$('.page-content')).render(); return this; } @@ -68,7 +75,6 @@ ['topics/:topic_id(/)', _.bind(this.browseTopic, this)], ['topics/:topic_id/search(/)', _.bind(this.searchTeams, this)], ['topics/:topic_id/create-team(/)', _.bind(this.newTeam, this)], - ['topics/:topic_id/:team_id/edit-team(/)', _.bind(this.editTeam, this)], ['teams/:topic_id/:team_id(/)', _.bind(this.browseTeam, this)], [new RegExp('^(browse)\/?$'), _.bind(this.goToTab, this)], [new RegExp('^(my-teams)\/?$'), _.bind(this.goToTab, this)] @@ -76,6 +82,17 @@ router.route.apply(router, route); }); + if (this.canEditTeam()) { + _.each([ + ['teams/:topic_id/:team_id/edit-team(/)', _.bind(this.editTeam, this)], + ['teams/:topic_id/:team_id/edit-team/manage-members(/)', + _.bind(this.editTeamMembers, this) + ] + ], function (route) { + router.route.apply(router, route); + }); + } + // Create an event queue to track team changes this.teamEvents = _.clone(Backbone.Events); @@ -169,7 +186,7 @@ render: function() { this.mainView.setElement(this.$el).render(); - this.hideWarning(); + TeamUtils.hideMessage(); return this; }, @@ -240,26 +257,54 @@ editTeam: function (topicID, teamID) { var self = this, editViewWithHeader; - this.getTopic(topicID).done(function (topic) { - self.getTeam(teamID, false).done(function(team) { - var view = new TeamEditView({ - action: 'edit', - teamEvents: self.teamEvents, - context: self.context, - topic: topic, - model: team - }); - editViewWithHeader = self.createViewWithHeader({ - title: gettext("Edit Team"), - description: gettext("If you make significant changes, make sure you notify members of the team before making these changes."), + $.when(this.getTopic(topicID), this.getTeam(teamID, false)).done(function(topic, team) { + var view = new TeamEditView({ + action: 'edit', + teamEvents: self.teamEvents, + context: self.context, + topic: topic, + model: team + }); + var instructorToolsView = new InstructorToolsView({ + team: team, + teamEvents: self.teamEvents + }); + editViewWithHeader = self.createViewWithHeader({ + title: gettext("Edit Team"), + description: gettext("If you make significant changes, make sure you notify members of the team before making these changes."), + mainView: view, + topic: topic, + team: team, + instructorTools: instructorToolsView + }); + self.mainView = editViewWithHeader; + self.render(); + TeamAnalytics.emitPageViewed('edit-team', topicID, teamID); + }); + }, + + /** + * + * The backbone router entry for editing team members, using topic and team IDs. + */ + editTeamMembers: function (topicID, teamID) { + var self = this; + $.when(this.getTopic(topicID), this.getTeam(teamID, true)).done(function(topic, team) { + var view = new TeamMembersEditView({ + teamEvents: self.teamEvents, + context: self.context, + model: team + }); + self.mainView = self.createViewWithHeader({ mainView: view, + title: gettext("Membership"), + description: gettext("You can remove members from this team, especially if they have not participated in the team's activity."), topic: topic, team: team - }); - self.mainView = editViewWithHeader; - self.render(); - TeamAnalytics.emitPageViewed('edit-team', topicID, teamID); - }); + } + ); + self.render(); + TeamAnalytics.emitPageViewed('edit-team-members', topicID, teamID); }); }, @@ -365,38 +410,41 @@ getBrowseTeamView: function (topicID, teamID) { var self = this, deferred = $.Deferred(); - self.getTopic(topicID).done(function(topic) { - self.getTeam(teamID, true).done(function(team) { - var view = new TeamProfileView({ - teamEvents: self.teamEvents, - router: self.router, - context: self.context, - model: team, - setFocusToHeaderFunc: self.setFocusToHeader - }); - var TeamProfileActionsView = new TeamProfileHeaderActionsView({ - teamEvents: self.teamEvents, - context: self.context, - model: team, - topic: topic, - showEditButton: self.context.userInfo.privileged || self.context.userInfo.staff - }); - deferred.resolve( - self.createViewWithHeader( - { - mainView: view, - subject: team, - topic: topic, - headerActionsView: TeamProfileActionsView - } - ) - ); + $.when(this.getTopic(topicID), this.getTeam(teamID, true)).done(function(topic, team) { + var view = new TeamProfileView({ + teamEvents: self.teamEvents, + router: self.router, + context: self.context, + model: team, + setFocusToHeaderFunc: self.setFocusToHeader }); + + var TeamProfileActionsView = new TeamProfileHeaderActionsView({ + teamEvents: self.teamEvents, + context: self.context, + model: team, + topic: topic, + showEditButton: self.canEditTeam() + }); + deferred.resolve( + self.createViewWithHeader( + { + mainView: view, + subject: team, + topic: topic, + headerActionsView: TeamProfileActionsView + } + ) + ); }); return deferred.promise(); }, + canEditTeam: function () { + return this.context.userInfo.privileged || this.context.userInfo.staff; + }, + createBreadcrumbs: function(topic, team) { var breadcrumbs = [{ title: gettext('All Topics'), @@ -446,7 +494,8 @@ } } }), - main: options.mainView + main: options.mainView, + instructorTools: options.instructorTools }); }, @@ -566,18 +615,7 @@ */ notFoundError: function (message) { this.router.navigate('my-teams', {trigger: true}); - this.showWarning(message); - }, - - showWarning: function (message) { - var warningEl = this.$('.warning'); - warningEl.find('.copy').html('

' + message + ' + + <%= username %>'s profile page + +

+ <%= username %> +
+ <%= dateJoined %> + | + <%= lastActive %> +
+
+ + diff --git a/lms/djangoapps/teams/static/teams/templates/instructor-tools.underscore b/lms/djangoapps/teams/static/teams/templates/instructor-tools.underscore new file mode 100644 index 0000000000..6618512dff --- /dev/null +++ b/lms/djangoapps/teams/static/teams/templates/instructor-tools.underscore @@ -0,0 +1,13 @@ +
+

+ <%- gettext("Instructor tools") %> +

+ + + + +
diff --git a/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore b/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore index ccbebcd603..b9141f333f 100644 --- a/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore +++ b/lms/djangoapps/teams/static/teams/templates/teams_tab.underscore @@ -1,4 +1,4 @@ -