From 0c3be8e121bcb5024757a23c6eefc3375211190c Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 31 Aug 2015 10:47:54 -0400 Subject: [PATCH] Delete Team functionality and tests TNL-3164 TNL-3163 --- common/test/acceptance/pages/common/utils.py | 62 +++++++ common/test/acceptance/pages/lms/teams.py | 53 ++++-- .../pages/studio/component_editor.py | 2 +- .../test/acceptance/pages/studio/container.py | 4 +- .../acceptance/pages/studio/import_export.py | 3 +- .../test/acceptance/pages/studio/library.py | 4 +- .../test/acceptance/pages/studio/overview.py | 4 +- .../studio/settings_group_configurations.py | 2 +- .../test/acceptance/pages/studio/textbooks.py | 2 +- common/test/acceptance/pages/studio/utils.py | 57 +----- .../acceptance/pages/studio/video/video.py | 2 +- .../tests/lms/test_lms_courseware_search.py | 3 +- .../tests/lms/test_lms_dashboard_search.py | 3 +- .../test/acceptance/tests/lms/test_teams.py | 167 ++++++++++++++---- lms/djangoapps/teams/search_indexes.py | 41 ++++- .../static/teams/js/collections/topic.js | 2 +- .../spec/collections/topic_collection_spec.js | 9 + .../js/spec/views/instructor_tools_spec.js | 84 +++++++++ .../teams/js/spec/views/teams_tab_spec.js | 2 +- .../js/spec_helpers/team_spec_helpers.js | 11 +- .../static/teams/js/views/instructor_tools.js | 41 ++++- .../teams/static/teams/js/views/team_utils.js | 21 ++- .../teams/static/teams/js/views/teams_tab.js | 25 +-- .../teams/static/teams/js/views/topics.js | 4 +- .../teams/templates/teams_tab.underscore | 2 +- lms/djangoapps/teams/tests/test_views.py | 56 +++++- lms/djangoapps/teams/views.py | 33 +++- lms/static/js/spec/main.js | 1 + 28 files changed, 545 insertions(+), 155 deletions(-) create mode 100644 common/test/acceptance/pages/common/utils.py create mode 100644 lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js 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 89805bf23e..7f295b4980 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 @@ -39,7 +39,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. """ @@ -88,7 +105,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" @@ -169,7 +186,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. @@ -207,6 +224,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) @@ -278,9 +300,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): """ @@ -289,7 +311,7 @@ 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']) @@ -309,11 +331,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""" @@ -329,8 +346,13 @@ 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): + +class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin): """ The page for a specific Team within the Teams tab """ @@ -479,11 +501,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 9d7376d11c..de6de83604 100644 --- a/common/test/acceptance/tests/lms/test_teams.py +++ b/common/test/acceptance/tests/lms/test_teams.py @@ -22,7 +22,15 @@ 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, + TeamPage +) +from ...pages.common.utils import confirm_prompt TOPICS_PER_PAGE = 12 @@ -384,7 +392,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 +401,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 +843,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 +881,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 +904,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 +949,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 +976,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 +998,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 +1010,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 +1056,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 +1088,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 +1117,96 @@ 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) + 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`.""" + self.team_page.click_edit_team_button() + self.team_management_page.wait_for_page() + self.team_management_page.delete_team_button.click() + 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 +1220,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 +1309,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 +1342,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 +1394,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() 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/instructor_tools_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js new file mode 100644 index 0000000000..586a0992c2 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/views/instructor_tools_spec.js @@ -0,0 +1,84 @@ +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); + }); + }); +}); 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 35a810f5ad..e0959f78ee 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 () { 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..f66682773e 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,15 @@ 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: '', + url: 'api/team/v0/teams/' + id }; }); }; @@ -124,6 +126,10 @@ define([ }); }; + var triggerTeamEvent = function (action) { + teamEvents.trigger('teams:update', {action: action}); + }; + createMockPostResponse = function(options) { return _.extend( { @@ -327,6 +333,7 @@ define([ createMockThreadResponse: createMockThreadResponse, createMockTopicData: createMockTopicData, createMockTopicCollection: createMockTopicCollection, + triggerTeamEvent: triggerTeamEvent, verifyCards: verifyCards }; }); diff --git a/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js b/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js index 418ca8c862..c39363bbc1 100644 --- a/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js +++ b/lms/djangoapps/teams/static/teams/js/views/instructor_tools.js @@ -5,8 +5,9 @@ 'underscore', 'gettext', 'teams/js/views/team_utils', + 'common/js/components/utils/view_utils', 'text!teams/templates/instructor-tools.underscore'], - function (Backbone, _, gettext, TeamUtils, instructorToolbarTemplate) { + function (Backbone, _, gettext, TeamUtils, ViewUtils, instructorToolbarTemplate) { return Backbone.View.extend({ events: { @@ -16,6 +17,8 @@ initialize: function(options) { this.template = _.template(instructorToolbarTemplate); + this.team = options.team; + this.teamEvents = options.teamEvents; }, render: function() { @@ -25,14 +28,46 @@ deleteTeam: function (event) { event.preventDefault(); - alert("You clicked the button!"); - //placeholder; will route to delete team page + 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(); alert("You clicked the button!"); //placeholder; will route to remove team member page + }, + + 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(); + } + }); } }); }); 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..904105a966 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_utils.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_utils.js @@ -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 d018839d2c..3850634ee4 100644 --- a/lms/djangoapps/teams/static/teams/js/views/teams_tab.js +++ b/lms/djangoapps/teams/static/teams/js/views/teams_tab.js @@ -20,8 +20,8 @@ 'teams/js/views/topic_teams', 'teams/js/views/edit_team', 'teams/js/views/team_profile_header_actions', - 'teams/js/views/team_utils', 'teams/js/views/instructor_tools', + 'teams/js/views/team_utils', 'text!teams/templates/teams_tab.underscore'], function (Backbone, _, gettext, SearchFieldView, HeaderView, HeaderModel, TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics, @@ -45,8 +45,9 @@ this.$el.html(_.template(teamsTemplate)); this.$('p.error').hide(); this.header.setElement(this.$('.teams-header')).render(); - if (this.instructorTools) + if (this.instructorTools) { this.instructorTools.setElement(this.$('.teams-instructor-tools-bar')).render(); + } this.main.setElement(this.$('.page-content')).render(); return this; } @@ -173,7 +174,7 @@ render: function() { this.mainView.setElement(this.$el).render(); - this.hideWarning(); + TeamUtils.hideMessage(); return this; }, @@ -253,7 +254,10 @@ topic: topic, model: team }); - var instructorToolsView = new InstructorToolsView(); + 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."), @@ -570,18 +574,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 + ' +