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
......
......@@ -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);
}
}
};
......
......@@ -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