Commit 17e64535 by Eric Fischer

Merge pull request #9520 from edx/efischer/teams-instructor-toolbar

Teams Instructor Tools bar
parents 55b2ea37 65cbab73
"""
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)
......@@ -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):
......@@ -314,11 +334,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
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"""
return self.q(css='.create-team.wrapper-msg .copy')[0].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 """
......
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
......
......@@ -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):
......
......@@ -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
......
......@@ -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
......
......@@ -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):
......
......@@ -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):
......
......@@ -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):
......
......@@ -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.
......
......@@ -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 = {
......
......@@ -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
......
......@@ -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
......
......@@ -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'))
......@@ -1102,6 +1119,131 @@ class CreateTeamTest(TeamFormActions):
@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):
"""
Tests for editing the team.
......@@ -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):
......
......@@ -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'])
......@@ -25,7 +25,7 @@
},
onUpdate: function(event) {
if (event.action === 'create') {
if (_.contains(['create', 'delete'], event.action)) {
this.isStale = true;
}
},
......
......@@ -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);
});
});
});
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('<div id="page-prompt"></div><div class="teams-content"></div>');
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);
});
});
});
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('<div id="page-prompt"></div>');
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}
);
});
});
});
......@@ -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, {});
......
......@@ -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
};
});
;(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('<p>' + gettext('This team does not have any members.') + '</p>');
}
else {
this.$el.html('<ul class="edit-members"></ul>');
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);
;(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);
......@@ -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;
......
......@@ -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}
);
}
......
/* 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);
}
}
};
......
......@@ -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('<p>' + message + '</p');
warningEl.toggleClass('is-hidden', false);
warningEl.focus();
},
hideWarning: function () {
this.$('.warning').toggleClass('is-hidden', true);
TeamUtils.showMessage(message);
},
/**
......
......@@ -3,9 +3,10 @@
define([
'gettext',
'teams/js/views/topic_card',
'teams/js/views/team_utils',
'common/js/components/views/paging_header',
'common/js/components/views/paginated_view'
], function (gettext, TopicCardView, PagingHeader, PaginatedView) {
], function (gettext, TopicCardView, TeamUtils, PagingHeader, PaginatedView) {
var TopicsView = PaginatedView.extend({
type: 'topics',
......@@ -35,6 +36,7 @@
this.collection.refresh()
.done(function() {
PaginatedView.prototype.render.call(self);
TeamUtils.hideMessage();
});
return this;
}
......
<li class="team-member">
<a class="member-profile" href="<%= memberProfileUrl %>">
<img class="image-url" src="<%= imageUrl %>" alt="<%= username %>'s profile page" />
</a>
<div class="member-info-container">
<span class="primary"><%= username %></span>
<div class="secondary">
<span id="date-joined"><%= dateJoined %></span>
<span> | </span>
<span id="last-active"><%= lastActive %></span>
</div>
</div>
<button class="action-remove-member" data-username="<%= username %>">
<%- gettext("Remove") %><span class="sr">&nbsp;<%= username %></span>
</button>
</li>
<div class="wrapper-msg">
<h3 class="left-floater">
<%- gettext("Instructor tools") %>
</h3>
<span class="right-floater">
<button class="action-delete">
<%- gettext("Delete Team") %>
</button>
<button class="action-edit-members">
<%- gettext("Edit Membership") %>
</button>
</span>
</div>
<div class="wrapper-msg is-incontext urgency-low warning is-hidden" tabindex="-1">
<div id="teams-message" class="wrapper-msg is-incontext urgency-low is-hidden" tabindex="-1">
<div class="msg">
<div class="msg-content">
<div class="copy">
......@@ -7,6 +7,7 @@
</div>
</div>
<div class="teams-header"></div>
<div class="teams-instructor-tools-bar"></div>
<div class="teams-main">
<div class="page-content"></div>
</div>
......@@ -21,6 +21,7 @@ from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from .factories import CourseTeamFactory, LAST_ACTIVITY_AT
from ..models import CourseTeamMembership
from ..search_indexes import CourseTeamIndexer, CourseTeam, course_team_post_save_callback
from django_comment_common.models import Role, FORUM_ROLE_COMMUNITY_TA
......@@ -339,6 +340,10 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
"""Gets detailed team information for team_id. Verifies expected_status."""
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'get', data, **kwargs)
def delete_team(self, team_id, expected_status, **kwargs):
"""Delete the given team. Verifies expected_status."""
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'delete', **kwargs)
def patch_team_detail(self, team_id, expected_status, data=None, **kwargs):
"""Patches the team with team_id using data. Verifies expected_status."""
return self.make_call(
......@@ -384,12 +389,8 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
def delete_membership(self, team_id, username, expected_status=200, **kwargs):
"""Deletes an individual membership record. Verifies expected_status."""
return self.make_call(
reverse('team_membership_detail', args=[team_id, username]),
expected_status,
'delete',
**kwargs
)
url = reverse('team_membership_detail', args=[team_id, username]) + '?admin=true'
return self.make_call(url, expected_status, 'delete', **kwargs)
def verify_expanded_public_user(self, user):
"""Verifies that fields exist on the returned user json indicating that it is expanded."""
......@@ -437,8 +438,9 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
def verify_names(self, data, status, names=None, **kwargs):
"""Gets a team listing with data as query params, verifies status, and then verifies team names if specified."""
teams = self.get_teams_list(data=data, expected_status=status, **kwargs)
if names:
self.assertEqual(names, [team['name'] for team in teams['results']])
if names is not None and 200 <= status < 300:
results = teams['results']
self.assertEqual(names, [team['name'] for team in results])
def test_filter_invalid_course_id(self):
self.verify_names({'course_id': 'no_such_course'}, 400)
......@@ -562,6 +564,26 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
user='student_enrolled_public_profile'
)
def test_delete_removed_from_search(self):
team = CourseTeamFactory.create(
name=u'zoinks',
course_id=self.test_course_1.id,
topic_id='topic_0'
)
self.verify_names(
{'course_id': self.test_course_1.id, 'text_search': 'zoinks'},
200,
[team.name],
user='staff'
)
team.delete()
self.verify_names(
{'course_id': self.test_course_1.id, 'text_search': 'zoinks'},
200,
[],
user='staff'
)
@ddt.ddt
class TestCreateTeamAPI(EventTestMixin, TeamAPITestCase):
......@@ -777,6 +799,60 @@ class TestDetailTeamAPI(TeamAPITestCase):
@ddt.ddt
class TestDeleteTeamAPI(EventTestMixin, TeamAPITestCase):
"""Test cases for the team delete endpoint."""
def setUp(self): # pylint: disable=arguments-differ
super(TestDeleteTeamAPI, self).setUp('teams.views.tracker')
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 403),
('staff', 204),
('course_staff', 204),
('community_ta', 204)
)
@ddt.unpack
def test_access(self, user, status):
self.delete_team(self.solar_team.team_id, status, user=user)
if status == 204:
self.assert_event_emitted(
'edx.team.deleted',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id)
)
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id),
remove_method='team_deleted',
user_id=self.users['student_enrolled'].id
)
def test_does_not_exist(self):
self.delete_team('nonexistent', 404)
def test_memberships_deleted(self):
self.assertEqual(CourseTeamMembership.objects.filter(team=self.solar_team).count(), 1)
self.delete_team(self.solar_team.team_id, 204, user='staff')
self.assert_event_emitted(
'edx.team.deleted',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id)
)
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id),
remove_method='team_deleted',
user_id=self.users['student_enrolled'].id
)
self.assertEqual(CourseTeamMembership.objects.filter(team=self.solar_team).count(), 0)
@ddt.ddt
class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase):
"""Test cases for the team update endpoint."""
......@@ -1299,17 +1375,32 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase):
)
if status == 204:
remove_method = 'self_removal' if user == 'student_enrolled' else 'removed_by_admin'
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.solar_team.course_id),
user_id=self.users['student_enrolled'].id,
remove_method=remove_method
remove_method='removed_by_admin'
)
else:
self.assert_no_events_were_emitted()
def test_leave_team(self):
"""
The key difference between this test and test_access above is that
removal via "Edit Membership" and "Leave Team" emit different events
despite hitting the same API endpoint, due to the 'admin' query string.
"""
url = reverse('team_membership_detail', args=[self.solar_team.team_id, self.users['student_enrolled'].username])
self.make_call(url, 204, 'delete', user='student_enrolled')
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.solar_team.course_id),
user_id=self.users['student_enrolled'].id,
remove_method='self_removal'
)
def test_bad_team(self):
self.delete_membership('no_such_team', self.users['student_enrolled'].username, 404)
......
"""HTTP endpoints for the Teams API."""
from django.shortcuts import render_to_response
import logging
from django.shortcuts import get_object_or_404, render_to_response
from django.http import Http404
from django.conf import settings
from django.core.paginator import Paginator
......@@ -61,6 +63,8 @@ TEAM_MEMBERSHIPS_PER_PAGE = 2
TOPICS_PER_PAGE = 12
MAXIMUM_SEARCH_SIZE = 100000
log = logging.getLogger(__name__)
@receiver(post_save, sender=CourseTeam)
def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument
......@@ -504,7 +508,7 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""
**Use Cases**
Get or update a course team's information. Updates are supported
Get, update, or delete a course team's information. Updates are supported
only through merge patch.
**Example Requests**:
......@@ -513,6 +517,8 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"
DELETE /api/team/v0/teams/{team_id}
**Query Parameters for GET**
* expand: Comma separated list of types for which to return
......@@ -577,6 +583,20 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
If the update could not be completed due to validation errors, this
method returns a 400 error with all error messages in the
"field_errors" field of the returned JSON.
**Response Values for DELETE**
Only staff can delete teams. When a team is deleted, all
team memberships associated with that team are also
deleted. Returns 204 on successful deletion.
If the user is anonymous or inactive, a 401 is returned.
If the user is not course or global staff and does not
have discussion privileges, a 403 is returned.
If the user is logged in and the team does not exist, a 404 is returned.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,)
......@@ -588,6 +608,29 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""Returns the queryset used to access the given team."""
return CourseTeam.objects.all()
def delete(self, request, team_id):
"""DELETE /api/team/v0/teams/{team_id}"""
team = get_object_or_404(CourseTeam, team_id=team_id)
self.check_object_permissions(request, team)
# Note: list() forces the queryset to be evualuated before delete()
memberships = list(CourseTeamMembership.get_memberships(team_id=team_id))
# Note: also deletes all team memberships associated with this team
team.delete()
log.info('user %d deleted team %s', request.user.id, team_id)
tracker.emit('edx.team.deleted', {
'team_id': team_id,
'course_id': unicode(team.course_id),
})
for member in memberships:
tracker.emit('edx.team.learner_removed', {
'team_id': team_id,
'course_id': unicode(team.course_id),
'remove_method': 'team_deleted',
'user_id': member.user_id
})
return Response(status=status.HTTP_204_NO_CONTENT)
class TopicListView(GenericAPIView):
"""
......@@ -1155,6 +1198,9 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
team = self.get_team(team_id)
if has_team_api_access(request.user, team.course_id, access_username=username):
membership = self.get_membership(username, team)
removal_method = 'self_removal'
if 'admin' in request.QUERY_PARAMS:
removal_method = 'removed_by_admin'
membership.delete()
tracker.emit(
'edx.team.learner_removed',
......@@ -1162,7 +1208,7 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
'team_id': team.team_id,
'course_id': unicode(team.course_id),
'user_id': membership.user.id,
'remove_method': 'self_removal' if membership.user == request.user else 'removed_by_admin'
'remove_method': removal_method
}
)
return Response(status=status.HTTP_204_NO_CONTENT)
......
......@@ -704,6 +704,8 @@
'lms/include/teams/js/spec/collections/topic_collection_spec.js',
'lms/include/teams/js/spec/teams_tab_factory_spec.js',
'lms/include/teams/js/spec/views/edit_team_spec.js',
'lms/include/teams/js/spec/views/edit_team_members_spec.js',
'lms/include/teams/js/spec/views/instructor_tools_spec.js',
'lms/include/teams/js/spec/views/my_teams_spec.js',
'lms/include/teams/js/spec/views/team_card_spec.js',
'lms/include/teams/js/spec/views/team_discussion_spec.js',
......
......@@ -232,3 +232,85 @@
}
}
}
//efischer; scratch space for teams instructor tools
.view-teams {
.wrapper-msg {
@include clearfix();
max-width: grid-width(12);
margin: 0 auto;
border-top: 3px solid $orange;
.left-floater {
@include float(left);
text-transform: uppercase;
font-weight: $font-semibold;
color: $white;
line-height: $body-line-height;
}
.right-floater {
@include float(right);
line-height: $body-line-height;
button {
background: transparent;
border: 1px solid transparent;
color: $white;
box-shadow: 0 0 0 0;
font-weight: $font-regular;
text-shadow: 0 0;
&:hover {
color: $orange;
background: transparent;
border: 1px solid $orange;
box-shadow: 0 0 0 0;
}
&:focus {
box-shadow: 0 0 0 0;
}
}
}
}
.edit-members {
@extend %ui-no-list;
.team-member {
line-height: $body-line-height;
padding: 10px;
}
.member-info-container {
display: inline-block;
vertical-align: middle;
@include margin-left($baseline/2);
.primary {
font-size: 120%;
}
.secondary {
color: $lighter-base-font-color;
font-size: 80%;
display: block;
}
}
.member-profile {
img {
border: 1px solid $gray;
}
}
.action-remove-member {
color: $blue;
background: transparent;
border: 1px solid transparent;
font: inherit;
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment