Commit 7c0663f2 by John Eskew

Merge remote-tracking branch 'origin/release' into jeskew/resolve_issues_with_release

parents ac0d9264 fa39016d
"""
Tests for the fix_not_found management command
"""
from django.core.management import call_command
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
class TestFixNotFound(ModuleStoreTestCase):
"""
Tests for the fix_not_found management command
"""
def test_fix_not_found_non_split(self):
"""
The management command doesn't work on non split courses
"""
course = CourseFactory(default_store=ModuleStoreEnum.Type.mongo)
with self.assertRaises(SystemExit):
call_command("fix_not_found", unicode(course.id))
def test_fix_not_found(self):
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
ItemFactory.create(category='chapter', parent_location=course.location)
# get course again in order to update its children list
course = self.store.get_course(course.id)
# create a dangling usage key that we'll add to the course's children list
dangling_pointer = course.id.make_usage_key('chapter', 'DanglingPointer')
course.children.append(dangling_pointer)
self.store.update_item(course, self.user.id)
# the course block should now point to two children, one of which
# doesn't actually exist
self.assertEqual(len(course.children), 2)
self.assertIn(dangling_pointer, course.children)
call_command("fix_not_found", unicode(course.id))
# make sure the dangling pointer was removed from
# the course block's children
course = self.store.get_course(course.id)
self.assertEqual(len(course.children), 1)
self.assertNotIn(dangling_pointer, course.children)
...@@ -46,8 +46,8 @@ function(_, str, Backbone, BackboneRelational, gettext) { ...@@ -46,8 +46,8 @@ function(_, str, Backbone, BackboneRelational, gettext) {
'title': gettext('Signatory title should span over maximum of 2 lines.') 'title': gettext('Signatory title should span over maximum of 2 lines.')
}, errors); }, errors);
} }
else if ((lines.length > 1 && (lines[0].length > 40 || lines[1].length > 40)) || else if ((lines.length > 1 && (lines[0].length > 53 && lines[1].length > 53)) ||
(lines.length === 1 && title.length > 40)) { (lines.length === 1 && title.length > 106)) {
errors = _.extend({ errors = _.extend({
'title': gettext('Signatory title should have maximum of 40 characters per line.') 'title': gettext('Signatory title should have maximum of 40 characters per line.')
}, errors); }, errors);
......
...@@ -246,7 +246,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails ...@@ -246,7 +246,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
}); });
setValuesToInputs(this.view, { setValuesToInputs(this.view, {
inputSignatoryTitle: 'New Signatory Test Title longer than 40 characters in length' inputSignatoryTitle: 'This is a certificate signatory title that has waaaaaaay more than 106 characters, in order to cause an exception.'
}); });
setValuesToInputs(this.view, { setValuesToInputs(this.view, {
......
...@@ -228,7 +228,7 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce ...@@ -228,7 +228,7 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce
} }
); );
it('signatories should not save when title has more than 40 characters per line', function() { it('signatories should not save when fields have too many characters per line', function() {
this.view.$(SELECTORS.addSignatoryButton).click(); this.view.$(SELECTORS.addSignatoryButton).click();
setValuesToInputs(this.view, { setValuesToInputs(this.view, {
inputCertificateName: 'New Certificate Name' inputCertificateName: 'New Certificate Name'
...@@ -239,7 +239,7 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce ...@@ -239,7 +239,7 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce
}); });
setValuesToInputs(this.view, { setValuesToInputs(this.view, {
inputSignatoryTitle: 'New Signatory title longer than 40 characters on one line' inputSignatoryTitle: 'This is a certificate signatory title that has waaaaaaay more than 106 characters, in order to cause an exception.'
}); });
setValuesToInputs(this.view, { setValuesToInputs(this.view, {
......
...@@ -574,6 +574,25 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja ...@@ -574,6 +574,25 @@ define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/aja
}); });
}); });
it('also works for older-style add component links', function () {
// Some third party xblocks (problem-builder in particular) expect add
// event handlers on custom <a> add buttons which is what the platform
// used to use instead of <button>s.
// This can be removed once there is a proper API that XBlocks can use
// to add children or allow authors to add children.
renderContainerPage(this, mockContainerXBlockHtml);
$(".add-xblock-component-button").each(function() {
var htmlAsLink = $($(this).prop('outerHTML').replace(/(<\/?)button/g, "$1a"));
$(this).replaceWith(htmlAsLink);
});
$(".add-xblock-component-button").first().click();
EditHelpers.verifyXBlockRequest(requests, {
"category": "discussion",
"type": "discussion",
"parent_locator": "locator-group-A"
});
});
it('shows a notification while creating', function () { it('shows a notification while creating', function () {
var notificationSpy = EditHelpers.createNotificationSpy(); var notificationSpy = EditHelpers.createNotificationSpy();
renderContainerPage(this, mockContainerXBlockHtml); renderContainerPage(this, mockContainerXBlockHtml);
......
...@@ -6,8 +6,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo ...@@ -6,8 +6,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) { function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({ var AddXBlockComponent = BaseView.extend({
events: { events: {
'click .new-component .new-component-type button.multiple-templates': 'showComponentTemplates', 'click .new-component .new-component-type .multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type button.single-template': 'createNewComponent', 'click .new-component .new-component-type .single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent', 'click .new-component .cancel-button': 'closeNewComponent',
'click .new-component-templates .new-component-template .button-component': 'createNewComponent', 'click .new-component-templates .new-component-template .button-component': 'createNewComponent',
'click .new-component-templates .cancel-button': 'closeNewComponent' 'click .new-component-templates .cancel-button': 'closeNewComponent'
......
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
</div> </div>
<div class="input-wrap field text add-signatory-title <% if(error && error.title) { print('error'); } %>"> <div class="input-wrap field text add-signatory-title <% if(error && error.title) { print('error'); } %>">
<label for="signatory-title-<%= signatory_number %>"><%= gettext("Title ") %></label> <label for="signatory-title-<%= signatory_number %>"><%= gettext("Title ") %></label>
<textarea id="signatory-title-<%= signatory_number %>" class="collection-name-input text input-text signatory-title-input" name="signatory-title" cols="40" rows="2" placeholder="<%= gettext("Title of the signatory") %>" aria-describedby="signatory-title-<%= signatory_number %>-tip" maxlength="80"><%= title %></textarea> <textarea id="signatory-title-<%= signatory_number %>" class="collection-name-input text input-text signatory-title-input" name="signatory-title" cols="40" rows="2" placeholder="<%= gettext("Title of the signatory") %>" aria-describedby="signatory-title-<%= signatory_number %>-tip" maxlength="106"><%= title %></textarea>
<span id="signatory-title-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("The title of this signatory as it should appear on certificates. Maximum 2 lines, 40 characters each.") %></span> <span id="signatory-title-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("The title of this signatory as it should appear on certificates. Maximum of 106 characters.") %></span>
<% if(error && error.title) { %> <% if(error && error.title) { %>
<span class="message-error"><%= error.title %></span> <span class="message-error"><%= error.title %></span>
<% } %> <% } %>
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
<span id="signatory-signature-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Image must be 450px X 150px transparent PNG") %></span> <span id="signatory-signature-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Image must be 450px X 150px transparent PNG") %></span>
</div> </div>
<button type="button" class="action action-upload-signature">Upload Signature Image</button> <button type="button" class="action action-upload-signature">Upload Signature Image</button>
</div> </div>
</div> </div>
</fieldset> </fieldset>
</div> </div>
......
...@@ -100,6 +100,9 @@ class CourseMode(models.Model): ...@@ -100,6 +100,9 @@ class CourseMode(models.Model):
# Modes that allow a student to pursue a verified certificate # Modes that allow a student to pursue a verified certificate
VERIFIED_MODES = [VERIFIED, PROFESSIONAL] VERIFIED_MODES = [VERIFIED, PROFESSIONAL]
# Modes that allow a student to pursue a non-verified certificate
NON_VERIFIED_MODES = [HONOR, AUDIT, NO_ID_PROFESSIONAL_MODE]
# Modes that allow a student to earn credit with a university partner # Modes that allow a student to earn credit with a university partner
CREDIT_MODES = [CREDIT_MODE] CREDIT_MODES = [CREDIT_MODE]
......
...@@ -13,6 +13,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -13,6 +13,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from certificates.api import get_certificate_url # pylint: disable=import-error from certificates.api import get_certificate_url # pylint: disable=import-error
from course_modes.models import CourseMode
# pylint: disable=no-member # pylint: disable=no-member
...@@ -42,6 +43,15 @@ class CertificateDisplayTest(ModuleStoreTestCase): ...@@ -42,6 +43,15 @@ class CertificateDisplayTest(ModuleStoreTestCase):
self._create_certificate(enrollment_mode) self._create_certificate(enrollment_mode)
self._check_can_download_certificate() self._check_can_download_certificate()
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_display_verified_certificate_no_id(self):
"""
Confirm that if we get a certificate with a no-id-professional mode
we still can download our certificate
"""
self._create_certificate(CourseMode.NO_ID_PROFESSIONAL_MODE)
self._check_can_download_certificate_no_id()
@ddt.data('verified', 'honor') @ddt.data('verified', 'honor')
@override_settings(CERT_NAME_SHORT='Test_Certificate') @override_settings(CERT_NAME_SHORT='Test_Certificate')
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) @patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
...@@ -105,6 +115,16 @@ class CertificateDisplayTest(ModuleStoreTestCase): ...@@ -105,6 +115,16 @@ class CertificateDisplayTest(ModuleStoreTestCase):
self.assertContains(response, u'Download Your ID Verified') self.assertContains(response, u'Download Your ID Verified')
self.assertContains(response, self.DOWNLOAD_URL) self.assertContains(response, self.DOWNLOAD_URL)
def _check_can_download_certificate_no_id(self):
"""
Inspects the dashboard to see if a certificate for a non verified course enrollment
is present
"""
response = self.client.get(reverse('dashboard'))
self.assertContains(response, u'Download')
self.assertContains(response, u'(PDF)')
self.assertContains(response, self.DOWNLOAD_URL)
def _check_can_not_download_certificate(self): def _check_can_not_download_certificate(self):
""" """
Make sure response does not have any of the download certificate buttons Make sure response does not have any of the download certificate buttons
......
...@@ -97,13 +97,10 @@ class DiscussionModule(DiscussionFields, XModule): ...@@ -97,13 +97,10 @@ class DiscussionModule(DiscussionFields, XModule):
def get_course(self): def get_course(self):
""" """
Return the CourseDescriptor at the root of the tree we're in. Return CourseDescriptor by course id.
""" """
block = self course = self.runtime.modulestore.get_course(self.course_id)
while block.parent: return course
block = block.get_parent()
return block
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor): class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
......
...@@ -312,7 +312,7 @@ class SplitBulkWriteMixin(BulkOperationsMixin): ...@@ -312,7 +312,7 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
if bulk_write_record.active: if bulk_write_record.active:
bulk_write_record.index = updated_index_entry bulk_write_record.index = updated_index_entry
else: else:
self.db_connection.update_course_index(updated_index_entry, course_key) self.db_connection.update_course_index(updated_index_entry, course_context=course_key)
def get_structure(self, course_key, version_guid): def get_structure(self, course_key, version_guid):
bulk_write_record = self._get_bulk_ops_record(course_key) bulk_write_record = self._get_bulk_ops_record(course_key)
......
...@@ -90,16 +90,20 @@ ...@@ -90,16 +90,20 @@
*/ */
setPage: function (page) { setPage: function (page) {
var oldPage = this.currentPage, var oldPage = this.currentPage,
self = this; self = this,
return this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then( deferred = $.Deferred();
this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
function () { function () {
self.isStale = false; self.isStale = false;
self.trigger('page_changed'); self.trigger('page_changed');
deferred.resolve();
}, },
function () { function () {
self.currentPage = oldPage; self.currentPage = oldPage;
deferred.fail();
} }
); );
return deferred.promise();
}, },
......
<div class="page-header-search wrapper-search-<%= type %>"> <div class="page-header-search wrapper-search-<%= type %>">
<form class="search-form"> <form class="search-form">
<div class="wrapper-search-input"> <div class="wrapper-search-input">
<label for="search-<%= type %>" class="search-label">><%- searchLabel %></label> <label for="search-<%= type %>" class="search-label"><%- searchLabel %></label>
<input id="search-<%= type %>" class="search-field" type="text" value="<%- searchString %>" placeholder="<%- searchLabel %>" /> <input id="search-<%= type %>" class="search-field" type="text" value="<%- searchString %>" placeholder="<%- searchLabel %>" />
<button type="button" class="action action-clear <%= searchLabel ? '' : 'is-hidden' %>" aria-label="<%- gettext('Clear search') %>"> <button type="button" class="action action-clear <%= searchLabel ? '' : 'is-hidden' %>" aria-label="<%- gettext('Clear search') %>">
<i class="icon fa fa-times-circle" aria-hidden="true"></i><span class="sr"><%- gettext('Search') %></span> <i class="icon fa fa-times-circle" aria-hidden="true"></i><span class="sr"><%- gettext('Search') %></span>
......
...@@ -13,8 +13,8 @@ from .fields import FieldsMixin ...@@ -13,8 +13,8 @@ from .fields import FieldsMixin
TOPIC_CARD_CSS = 'div.wrapper-card-core' TOPIC_CARD_CSS = 'div.wrapper-card-core'
CARD_TITLE_CSS = 'h3.card-title' CARD_TITLE_CSS = 'h3.card-title'
MY_TEAMS_BUTTON_CSS = 'a.nav-item[data-index="0"]' MY_TEAMS_BUTTON_CSS = '.nav-item[data-index="0"]'
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]' BROWSE_BUTTON_CSS = '.nav-item[data-index="1"]'
TEAMS_LINK_CSS = '.action-view' TEAMS_LINK_CSS = '.action-view'
TEAMS_HEADER_CSS = '.teams-header' TEAMS_HEADER_CSS = '.teams-header'
CREATE_TEAM_LINK_CSS = '.create-team' CREATE_TEAM_LINK_CSS = '.create-team'
...@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team' ...@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team'
class TeamCardsMixin(object): class TeamCardsMixin(object):
"""Provides common operations on the team card component.""" """Provides common operations on the team card component."""
def _bounded_selector(self, css):
"""Bind the CSS to a particular tabpanel (e.g. My Teams or Browse)."""
return '{tabpanel_id} {css}'.format(tabpanel_id=getattr(self, 'tabpanel_id', ''), css=css)
def view_first_team(self): def view_first_team(self):
"""Click the 'view' button of the first team card on the page.""" """Click the 'view' button of the first team card on the page."""
self.q(css='a.action-view').first.click() self.q(css=self._bounded_selector('a.action-view')).first.click()
@property @property
def team_cards(self): def team_cards(self):
"""Get all the team cards on the page.""" """Get all the team cards on the page."""
return self.q(css='.team-card') return self.q(css=self._bounded_selector('.team-card'))
@property @property
def team_names(self): def team_names(self):
"""Return the names of each team on the page.""" """Return the names of each team on the page."""
return self.q(css='h3.card-title').map(lambda e: e.text).results return self.q(css=self._bounded_selector('h3.card-title')).map(lambda e: e.text).results
@property @property
def team_descriptions(self): def team_descriptions(self):
"""Return the names of each team on the page.""" """Return the names of each team on the page."""
return self.q(css='p.card-description').map(lambda e: e.text).results return self.q(css=self._bounded_selector('p.card-description')).map(lambda e: e.text).results
class BreadcrumbsMixin(object): class BreadcrumbsMixin(object):
...@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): ...@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
""" """
url_path = "teams/#my-teams" url_path = "teams/#my-teams"
tabpanel_id = '#tabpanel-my-teams'
def is_browser_on_page(self): def is_browser_on_page(self):
"""Check if the "My Teams" tab is being viewed.""" """Check if the "My Teams" tab is being viewed."""
...@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
@property @property
def topic_names(self): def topic_names(self):
"""Return a list of the topic names present on the page.""" """Return a list of the topic names present on the page."""
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results return self.q(css='#tabpanel-browse ' + CARD_TITLE_CSS).map(lambda e: e.text).results
@property @property
def topic_descriptions(self): def topic_descriptions(self):
...@@ -508,7 +513,7 @@ class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin): ...@@ -508,7 +513,7 @@ class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin):
def click_first_profile_image(self): def click_first_profile_image(self):
"""Clicks on first team member's profile image""" """Clicks on first team member's profile image"""
self.q(css='.page-content-secondary .members-info > .team-member').first.click() self.q(css='.page-content-secondary .members-info .team-member').first.click()
@property @property
def first_member_username(self): def first_member_username(self):
......
...@@ -334,7 +334,7 @@ class EventsTestMixin(TestCase): ...@@ -334,7 +334,7 @@ class EventsTestMixin(TestCase):
captured_events.append(event) captured_events.append(event)
@contextmanager @contextmanager
def assert_events_match_during(self, event_filter=None, expected_events=None): def assert_events_match_during(self, event_filter=None, expected_events=None, in_order=True):
""" """
Context manager that ensures that events matching the `event_filter` and `expected_events` are emitted. Context manager that ensures that events matching the `event_filter` and `expected_events` are emitted.
...@@ -351,7 +351,7 @@ class EventsTestMixin(TestCase): ...@@ -351,7 +351,7 @@ class EventsTestMixin(TestCase):
with self.capture_events(event_filter, len(expected_events), captured_events): with self.capture_events(event_filter, len(expected_events), captured_events):
yield yield
self.assert_events_match(expected_events, captured_events) self.assert_events_match(expected_events, captured_events, in_order=in_order)
def wait_for_events(self, start_time=None, event_filter=None, number_of_matches=1, timeout=None): def wait_for_events(self, start_time=None, event_filter=None, number_of_matches=1, timeout=None):
""" """
...@@ -477,17 +477,29 @@ class EventsTestMixin(TestCase): ...@@ -477,17 +477,29 @@ class EventsTestMixin(TestCase):
self.assertEquals(len(matching_events), 0, description) self.assertEquals(len(matching_events), 0, description)
def assert_events_match(self, expected_events, actual_events): def assert_events_match(self, expected_events, actual_events, in_order=True):
""" """Assert that each actual event matches one of the expected events.
Assert that each item in the expected events sequence matches its counterpart at the same index in the actual
events sequence. Args:
expected_events (List): a list of dicts representing the expected events.
actual_events (List): a list of dicts that were actually recorded.
in_order (bool): if True then the events must be in the same order (defaults to True).
""" """
for expected_event, actual_event in zip(expected_events, actual_events): if in_order:
assert_event_matches( for expected_event, actual_event in zip(expected_events, actual_events):
expected_event, assert_event_matches(
actual_event, expected_event,
tolerate=EventMatchTolerates.lenient() actual_event,
) tolerate=EventMatchTolerates.lenient()
)
else:
for expected_event in expected_events:
actual_event = next(event for event in actual_events if is_matching_event(expected_event, event))
assert_event_matches(
expected_event,
actual_event or {},
tolerate=EventMatchTolerates.lenient()
)
def relative_path_to_absolute_uri(self, relative_path): def relative_path_to_absolute_uri(self, relative_path):
"""Return an aboslute URI given a relative path taking into account the test context.""" """Return an aboslute URI given a relative path taking into account the test context."""
......
...@@ -9,7 +9,6 @@ from dateutil.parser import parse ...@@ -9,7 +9,6 @@ from dateutil.parser import parse
import ddt import ddt
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from uuid import uuid4 from uuid import uuid4
from unittest import skip
from ..helpers import EventsTestMixin, UniqueCourseTest from ..helpers import EventsTestMixin, UniqueCourseTest
from ...fixtures import LMS_BASE_URL from ...fixtures import LMS_BASE_URL
...@@ -783,7 +782,6 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase): ...@@ -783,7 +782,6 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
self.browse_teams_page.click_browse_all_teams_link() self.browse_teams_page.click_browse_all_teams_link()
self.assertTrue(self.topics_page.is_browser_on_page()) self.assertTrue(self.topics_page.is_browser_on_page())
@skip("Skip until TNL-3198 (searching teams makes two AJAX requests) is resolved")
def test_search(self): def test_search(self):
""" """
Scenario: User should be able to search for a team Scenario: User should be able to search for a team
...@@ -794,6 +792,7 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase): ...@@ -794,6 +792,7 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
And the search header should be shown And the search header should be shown
And 0 results should be shown And 0 results should be shown
And my browser should fire a page viewed event for the search page And my browser should fire a page viewed event for the search page
And a searched event should have been fired
""" """
# Note: all searches will return 0 results with the mock search server # Note: all searches will return 0 results with the mock search server
# used by Bok Choy. # used by Bok Choy.
...@@ -801,21 +800,21 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase): ...@@ -801,21 +800,21 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
self.create_teams(self.topic, 5) self.create_teams(self.topic, 5)
self.browse_teams_page.visit() self.browse_teams_page.visit()
events = [{ events = [{
'event_type': 'edx.team.searched', 'event_type': 'edx.team.page_viewed',
'event': { 'event': {
'search_text': search_text, 'page_name': 'search-teams',
'topic_id': self.topic['id'], 'topic_id': self.topic['id'],
'number_of_results': 0 'team_id': None
} }
}, { }, {
'event_type': 'edx.team.page_viewed', 'event_type': 'edx.team.searched',
'event': { 'event': {
'page_name': 'search-teams', 'search_text': search_text,
'topic_id': self.topic['id'], 'topic_id': self.topic['id'],
'team_id': None 'number_of_results': 0
} }
}] }]
with self.assert_events_match_during(self.only_team_events, expected_events=events): with self.assert_events_match_during(self.only_team_events, expected_events=events, in_order=False):
search_results_page = self.browse_teams_page.search(search_text) search_results_page = self.browse_teams_page.search(search_text)
self.verify_search_header(search_results_page, search_text) self.verify_search_header(search_results_page, search_text)
self.assertTrue(search_results_page.get_pagination_header_text().startswith('Showing 0 out of 0 total')) self.assertTrue(search_results_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Test for Discussion Xmodule functional logic.""" """Test for Discussion Xmodule functional logic."""
import ddt
from mock import Mock from mock import Mock
from . import BaseTestXmodule from . import BaseTestXmodule
from courseware.module_render import get_module_for_descriptor_internal from courseware.module_render import get_module_for_descriptor_internal
from xmodule.discussion_module import DiscussionModule
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from student.tests.factories import UserFactory
@ddt.ddt
class DiscussionModuleTest(BaseTestXmodule): class DiscussionModuleTest(BaseTestXmodule):
"""Logic tests for Discussion Xmodule.""" """Logic tests for Discussion Xmodule."""
CATEGORY = "discussion" CATEGORY = "discussion"
...@@ -24,3 +30,63 @@ class DiscussionModuleTest(BaseTestXmodule): ...@@ -24,3 +30,63 @@ class DiscussionModuleTest(BaseTestXmodule):
html = fragment.content html = fragment.content
self.assertIn('data-user-create-comment="false"', html) self.assertIn('data-user-create-comment="false"', html)
self.assertIn('data-user-create-subcomment="false"', html) self.assertIn('data-user-create-subcomment="false"', html)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_discussion_render_successfully_with_orphan_parent(self, default_store):
"""
Test that discussion module render successfully
if discussion module is child of an orphan.
"""
user = UserFactory.create()
store = modulestore()
with store.default_store(default_store):
course = store.create_course('testX', 'orphan', '123X', user.id)
orphan_sequential = store.create_item(self.user.id, course.id, 'sequential')
vertical = store.create_child(
user.id,
orphan_sequential.location,
'vertical',
block_id=course.location.block_id
)
discussion = store.create_child(
user.id,
vertical.location,
'discussion',
block_id=course.location.block_id
)
discussion = store.get_item(discussion.location)
root = self.get_root(discussion)
# Assert that orphan sequential is root of the discussion module.
self.assertEqual(orphan_sequential.location.block_type, root.location.block_type)
self.assertEqual(orphan_sequential.location.block_id, root.location.block_id)
# Get module system bound to a user and a descriptor.
discussion_module = get_module_for_descriptor_internal(
user=user,
descriptor=discussion,
student_data=Mock(name='student_data'),
course_id=course.id,
track_function=Mock(name='track_function'),
xqueue_callback_url_prefix=Mock(name='xqueue_callback_url_prefix'),
request_token='request_token',
)
fragment = discussion_module.render('student_view')
html = fragment.content
self.assertIsInstance(discussion_module._xmodule, DiscussionModule) # pylint: disable=protected-access
self.assertIn('data-user-create-comment="false"', html)
self.assertIn('data-user-create-subcomment="false"', html)
def get_root(self, block):
"""
Return root of the block.
"""
while block.parent:
block = block.get_parent()
return block
...@@ -16,6 +16,11 @@ class AlreadyOnTeamInCourse(TeamAPIRequestError): ...@@ -16,6 +16,11 @@ class AlreadyOnTeamInCourse(TeamAPIRequestError):
pass pass
class ElasticSearchConnectionError(TeamAPIRequestError):
"""The system was unable to connect to the configured elasticsearch instance."""
pass
class ImmutableMembershipFieldException(Exception): class ImmutableMembershipFieldException(Exception):
"""An attempt was made to change an immutable field on a CourseTeamMembership model""" """An attempt was made to change an immutable field on a CourseTeamMembership model."""
pass pass
""" Search index used to load data into elasticsearch""" """ Search index used to load data into elasticsearch"""
import logging
from elasticsearch.exceptions import ConnectionError
from django.conf import settings from django.conf import settings
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
...@@ -8,6 +11,7 @@ from functools import wraps ...@@ -8,6 +11,7 @@ from functools import wraps
from search.search_engine_base import SearchEngine from search.search_engine_base import SearchEngine
from .errors import ElasticSearchConnectionError
from .serializers import CourseTeamSerializer, CourseTeam from .serializers import CourseTeamSerializer, CourseTeam
...@@ -103,8 +107,11 @@ class CourseTeamIndexer(object): ...@@ -103,8 +107,11 @@ class CourseTeamIndexer(object):
""" """
Return course team search engine (if feature is enabled). Return course team search engine (if feature is enabled).
""" """
if cls.search_is_enabled(): try:
return SearchEngine.get_search_engine(index=cls.INDEX_NAME) return SearchEngine.get_search_engine(index=cls.INDEX_NAME)
except ConnectionError as err:
logging.error('Error connecting to elasticsearch: %s', err)
raise ElasticSearchConnectionError
@classmethod @classmethod
def search_is_enabled(cls): def search_is_enabled(cls):
...@@ -119,7 +126,10 @@ def course_team_post_save_callback(**kwargs): ...@@ -119,7 +126,10 @@ def course_team_post_save_callback(**kwargs):
""" """
Reindex object after save. Reindex object after save.
""" """
CourseTeamIndexer.index(kwargs['instance']) try:
CourseTeamIndexer.index(kwargs['instance'])
except ElasticSearchConnectionError:
pass
@receiver(post_delete, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_delete_callback') @receiver(post_delete, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_delete_callback')
...@@ -127,4 +137,7 @@ def course_team_post_delete_callback(**kwargs): # pylint: disable=invalid-name ...@@ -127,4 +137,7 @@ def course_team_post_delete_callback(**kwargs): # pylint: disable=invalid-name
""" """
Reindex object after delete. Reindex object after delete.
""" """
CourseTeamIndexer.remove(kwargs['instance']) try:
CourseTeamIndexer.remove(kwargs['instance'])
except ElasticSearchConnectionError:
pass
...@@ -24,7 +24,9 @@ define([ ...@@ -24,7 +24,9 @@ define([
it('can render itself', function () { it('can render itself', function () {
var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5), var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5),
teamsView = createTeamsView({ teamsView = createTeamsView({
teams: TeamSpecHelpers.createMockTeams(testTeamData) teams: TeamSpecHelpers.createMockTeams({
results: testTeamData
})
}); });
expect(teamsView.$('.teams-paging-header').text()).toMatch('Showing 1-5 out of 6 total'); expect(teamsView.$('.teams-paging-header').text()).toMatch('Showing 1-5 out of 6 total');
......
...@@ -29,19 +29,6 @@ define([ ...@@ -29,19 +29,6 @@ define([
return teamsTabView; return teamsTabView;
}; };
/**
* Filters out all team events from a list of requests.
*/
var removeTeamEvents = function (requests) {
return requests.filter(function (request) {
if (request.requestBody && request.requestBody.startsWith('event_type=edx.team')) {
return false;
} else {
return true;
}
});
};
beforeEach(function () { beforeEach(function () {
setFixtures('<div class="teams-content"></div>'); setFixtures('<div class="teams-content"></div>');
spyOn($.fn, 'focus'); spyOn($.fn, 'focus');
...@@ -233,25 +220,33 @@ define([ ...@@ -233,25 +220,33 @@ define([
options options
)); ));
}; };
var performSearch = function(requests, teamsTabView) {
teamsTabView.$('.search-field').val('foo');
teamsTabView.$('.action-search').click();
verifyTeamsRequest(requests, {
order_by: '',
text_search: 'foo'
});
AjaxHelpers.respondWithJson(requests, TeamSpecHelpers.createMockTeamsResponse({results: []}));
};
it('can search teams', function () { it('can search teams', function () {
var requests = AjaxHelpers.requests(this), var requests = AjaxHelpers.requests(this),
teamsTabView = createTeamsTabView(); teamsTabView = createTeamsTabView(),
requestCountBeforeSearch;
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID); teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
verifyTeamsRequest(requests, { verifyTeamsRequest(requests, {
order_by: 'last_activity_at', order_by: 'last_activity_at',
text_search: '' text_search: ''
}); });
AjaxHelpers.respondWithJson(requests, {}); AjaxHelpers.respondWithJson(requests, {});
teamsTabView.$('.search-field').val('foo'); requestCountBeforeSearch = requests.length;
teamsTabView.$('.action-search').click(); performSearch(requests, teamsTabView);
verifyTeamsRequest(requests, {
order_by: '',
text_search: 'foo'
});
AjaxHelpers.respondWithJson(requests, {});
expect(teamsTabView.$('.page-title').text()).toBe('Team Search'); expect(teamsTabView.$('.page-title').text()).toBe('Team Search');
expect(teamsTabView.$('.page-description').text()).toBe('Showing results for "foo"'); expect(teamsTabView.$('.page-description').text()).toBe('Showing results for "foo"');
// Expect exactly one search request to be fired
expect(requests.length).toBe(requestCountBeforeSearch + 1);
}); });
it('can clear a search', function () { it('can clear a search', function () {
...@@ -259,17 +254,10 @@ define([ ...@@ -259,17 +254,10 @@ define([
teamsTabView = createTeamsTabView(); teamsTabView = createTeamsTabView();
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID); teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
AjaxHelpers.respondWithJson(requests, {}); AjaxHelpers.respondWithJson(requests, {});
performSearch(requests, teamsTabView);
// Perform a search // Perform a search
teamsTabView.$('.search-field').val('foo'); performSearch(requests, teamsTabView);
teamsTabView.$('.action-search').click();
// Note: this is a bit of a hack -- without it the URL
// fragment won't be what it would be in the real
// app. This line sets the fragment without triggering
// callbacks, allowing teams_tab.js to detect the
// fragment correctly.
Backbone.history.navigate('topics/' + TeamSpecHelpers.testTopicID + '/search', {trigger: false});
AjaxHelpers.respondWithJson(requests, {});
// Clear the search and submit it again // Clear the search and submit it again
teamsTabView.$('.search-field').val(''); teamsTabView.$('.search-field').val('');
...@@ -283,16 +271,39 @@ define([ ...@@ -283,16 +271,39 @@ define([
expect(teamsTabView.$('.page-description').text()).toBe('Test description 1'); expect(teamsTabView.$('.page-description').text()).toBe('Test description 1');
}); });
it('does not switch to showing results when the search returns an error', function () { it('can navigate back to all teams from a search', function () {
var requests = AjaxHelpers.requests(this), var requests = AjaxHelpers.requests(this),
teamsTabView = createTeamsTabView(); teamsTabView = createTeamsTabView();
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID); teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
AjaxHelpers.respondWithJson(requests, {}); AjaxHelpers.respondWithJson(requests, {});
// Perform a search // Perform a search
performSearch(requests, teamsTabView);
// Verify the breadcrumbs have a link back to the teams list, and click on it
expect(teamsTabView.$('.breadcrumbs a').length).toBe(2);
teamsTabView.$('.breadcrumbs a').last().click();
verifyTeamsRequest(requests, {
order_by: 'last_activity_at',
text_search: ''
});
AjaxHelpers.respondWithJson(requests, {});
expect(teamsTabView.$('.page-title').text()).toBe('Test Topic 1');
expect(teamsTabView.$('.page-description').text()).toBe('Test description 1');
});
it('does not switch to showing results when the search returns an error', function () {
var requests = AjaxHelpers.requests(this),
teamsTabView = createTeamsTabView();
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
AjaxHelpers.respondWithJson(requests, {});
// Perform a search but respond with a 500
teamsTabView.$('.search-field').val('foo'); teamsTabView.$('.search-field').val('foo');
teamsTabView.$('.action-search').click(); teamsTabView.$('.action-search').click();
AjaxHelpers.respondWithError(requests); AjaxHelpers.respondWithError(requests);
// Verify that the team list is still shown
expect(teamsTabView.$('.page-title').text()).toBe('Test Topic 1'); expect(teamsTabView.$('.page-title').text()).toBe('Test Topic 1');
expect(teamsTabView.$('.page-description').text()).toBe('Test description 1'); expect(teamsTabView.$('.page-description').text()).toBe('Test description 1');
expect(teamsTabView.$('.search-field').val(), 'foo'); expect(teamsTabView.$('.search-field').val(), 'foo');
......
...@@ -44,7 +44,9 @@ define([ ...@@ -44,7 +44,9 @@ define([
it('can render itself', function () { it('can render itself', function () {
var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5), var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5),
teamsView = createTopicTeamsView({ teamsView = createTopicTeamsView({
teams: TeamSpecHelpers.createMockTeams(testTeamData), teams: TeamSpecHelpers.createMockTeams({
results: testTeamData
}),
teamMemberships: TeamSpecHelpers.createMockTeamMemberships([]) teamMemberships: TeamSpecHelpers.createMockTeamMemberships([])
}); });
......
...@@ -43,18 +43,22 @@ define([ ...@@ -43,18 +43,22 @@ define([
}); });
}; };
var createMockTeams = function(teamData) { var createMockTeamsResponse = function(options) {
if (!teamData) { return _.extend(
teamData = createMockTeamData(1, 5);
}
return new TeamCollection(
{ {
count: 6, count: 6,
num_pages: 2, num_pages: 2,
current_page: 1, current_page: 1,
start: 0, start: 0,
results: teamData results: createMockTeamData(1, 5)
}, },
options
);
};
var createMockTeams = function(options) {
return new TeamCollection(
createMockTeamsResponse(options),
{ {
teamEvents: teamEvents, teamEvents: teamEvents,
course_id: testCourseID, course_id: testCourseID,
...@@ -325,6 +329,7 @@ define([ ...@@ -325,6 +329,7 @@ define([
testTeamDiscussionID: testTeamDiscussionID, testTeamDiscussionID: testTeamDiscussionID,
testContext: testContext, testContext: testContext,
createMockTeamData: createMockTeamData, createMockTeamData: createMockTeamData,
createMockTeamsResponse: createMockTeamsResponse,
createMockTeams: createMockTeams, createMockTeams: createMockTeams,
createMockTeamMembershipsData: createMockTeamMembershipsData, createMockTeamMembershipsData: createMockTeamMembershipsData,
createMockTeamMemberships: createMockTeamMemberships, createMockTeamMemberships: createMockTeamMemberships,
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) { TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) {
var TeamsHeaderModel = HeaderModel.extend({ var TeamsHeaderModel = HeaderModel.extend({
initialize: function () { initialize: function () {
_.extend(this.defaults, {nav_aria_label: gettext('teams')}); _.extend(this.defaults, {nav_aria_label: gettext('Topics')});
HeaderModel.prototype.initialize.call(this); HeaderModel.prototype.initialize.call(this);
} }
}); });
...@@ -214,6 +214,7 @@ ...@@ -214,6 +214,7 @@
view.mainView = view.createTeamsListView({ view.mainView = view.createTeamsListView({
topic: topic, topic: topic,
collection: view.teamsCollection, collection: view.teamsCollection,
breadcrumbs: view.createBreadcrumbs(topic),
title: gettext('Team Search'), title: gettext('Team Search'),
description: interpolate( description: interpolate(
gettext('Showing results for "%(searchString)s"'), gettext('Showing results for "%(searchString)s"'),
...@@ -337,6 +338,7 @@ ...@@ -337,6 +338,7 @@
var teamsView = view.createTeamsListView({ var teamsView = view.createTeamsListView({
topic: topic, topic: topic,
collection: collection, collection: collection,
breadcrumbs: view.createBreadcrumbs(),
showSortControls: true showSortControls: true
}); });
deferred.resolve(teamsView); deferred.resolve(teamsView);
...@@ -368,7 +370,7 @@ ...@@ -368,7 +370,7 @@
headerActionsView: searchFieldView, headerActionsView: searchFieldView,
title: options.title, title: options.title,
description: options.description, description: options.description,
breadcrumbs: this.createBreadcrumbs() breadcrumbs: options.breadcrumbs
}), }),
searchUrl = 'topics/' + topic.get('id') + '/search'; searchUrl = 'topics/' + topic.get('id') + '/search';
// Listen to requests to sync the collection and redirect it as follows: // Listen to requests to sync the collection and redirect it as follows:
...@@ -378,6 +380,11 @@ ...@@ -378,6 +380,11 @@
// 3. Otherwise, do nothing and remain on the current page. // 3. Otherwise, do nothing and remain on the current page.
// Note: Backbone makes this a no-op if redirecting to the current page. // Note: Backbone makes this a no-op if redirecting to the current page.
this.listenTo(collection, 'sync', function() { this.listenTo(collection, 'sync', function() {
// Clear the stale flag here as by definition the collection is up-to-date,
// and the flag itself isn't guaranteed to be cleared yet. This is to ensure
// that the collection doesn't unnecessarily get refreshed again.
collection.isStale = false;
if (collection.searchString) { if (collection.searchString) {
Backbone.history.navigate(searchUrl, {trigger: true}); Backbone.history.navigate(searchUrl, {trigger: true});
} else if (Backbone.history.getFragment() === searchUrl) { } else if (Backbone.history.getFragment() === searchUrl) {
......
<span class="team-member"> <li>
<span class="team-member">
<a class="member-profile" href="<%= memberProfileUrl %>"> <a class="member-profile" href="<%= memberProfileUrl %>">
<p class="tooltip-custom"><%= username %></p> <p class="tooltip-custom"><%= username %></p>
<img class="image-url" src="<%= imageUrl %>" alt="profile page" /> <img class="image-url" src="<%= imageUrl %>" alt="profile page" />
</a> </a>
</span> </span>
</li>
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
<% if (hasMembers) { %> <% if (hasMembers) { %>
<span class="sr"><%- gettext("Team member profiles") %></span> <span class="sr"><%- gettext("Team member profiles") %></span>
<% } %> <% } %>
<div class="members-info"></div> <ul class="members-info"></ul>
</div> </div>
<div class="team-capacity"> <div class="team-capacity">
......
...@@ -5,6 +5,9 @@ import pytz ...@@ -5,6 +5,9 @@ import pytz
from datetime import datetime from datetime import datetime
from dateutil import parser from dateutil import parser
import ddt import ddt
from elasticsearch.exceptions import ConnectionError
from mock import patch
from search.search_engine_base import SearchEngine
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
...@@ -1397,3 +1400,49 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase): ...@@ -1397,3 +1400,49 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase):
def test_missing_membership(self): def test_missing_membership(self):
self.delete_membership(self.wind_team.team_id, self.users['student_enrolled'].username, 404) self.delete_membership(self.wind_team.team_id, self.users['student_enrolled'].username, 404)
class TestElasticSearchErrors(TeamAPITestCase):
"""Test that the Team API is robust to Elasticsearch connection errors."""
ES_ERROR = ConnectionError('N/A', 'connection error', {})
@patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR)
def test_list_teams(self, __):
"""Test that text searches return a 503 when Elasticsearch is down.
The endpoint should still return 200 when a search is not supplied."""
self.get_teams_list(
expected_status=503,
data={'course_id': self.test_course_1.id, 'text_search': 'zoinks'},
user='staff'
)
self.get_teams_list(
expected_status=200,
data={'course_id': self.test_course_1.id},
user='staff'
)
@patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR)
def test_create_team(self, __):
"""Test that team creation is robust to Elasticsearch errors."""
self.post_create_team(
expected_status=200,
data=self.build_team_data(name='zoinks'),
user='staff'
)
@patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR)
def test_delete_team(self, __):
"""Test that team deletion is robust to Elasticsearch errors."""
self.delete_team(self.wind_team.team_id, 204, user='staff')
@patch.object(SearchEngine, 'get_search_engine', side_effect=ES_ERROR)
def test_patch_team(self, __):
"""Test that team updates are robust to Elasticsearch errors."""
self.patch_team_detail(
self.wind_team.team_id,
200,
data={'description': 'new description'},
user='staff'
)
...@@ -58,7 +58,7 @@ from .serializers import ( ...@@ -58,7 +58,7 @@ from .serializers import (
add_team_count add_team_count
) )
from .search_indexes import CourseTeamIndexer from .search_indexes import CourseTeamIndexer
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam from .errors import AlreadyOnTeamInCourse, ElasticSearchConnectionError, NotEnrolledInCourseForTeam
TEAM_MEMBERSHIPS_PER_PAGE = 2 TEAM_MEMBERSHIPS_PER_PAGE = 2
TOPICS_PER_PAGE = 12 TOPICS_PER_PAGE = 12
...@@ -293,6 +293,9 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -293,6 +293,9 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
example, the course_id may not reference a real course or the page example, the course_id may not reference a real course or the page
number may be beyond the last page. number may be beyond the last page.
If the server is unable to connect to Elasticsearch, and
the text_search parameter is supplied, a 503 error is returned.
**Response Values for POST** **Response Values for POST**
Any logged in user who has verified their email address can create Any logged in user who has verified their email address can create
...@@ -366,7 +369,14 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -366,7 +369,14 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
return Response(error, status=status.HTTP_400_BAD_REQUEST) return Response(error, status=status.HTTP_400_BAD_REQUEST)
result_filter.update({'topic_id': topic_id}) result_filter.update({'topic_id': topic_id})
if text_search and CourseTeamIndexer.search_is_enabled(): if text_search and CourseTeamIndexer.search_is_enabled():
search_engine = CourseTeamIndexer.engine() try:
search_engine = CourseTeamIndexer.engine()
except ElasticSearchConnectionError:
return Response(
build_api_error(ugettext_noop('Error connecting to elasticsearch')),
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
result_filter.update({'course_id': course_id_string}) result_filter.update({'course_id': course_id_string})
search_results = search_engine.search( search_results = search_engine.search(
......
...@@ -4,11 +4,37 @@ ...@@ -4,11 +4,37 @@
'underscore', 'underscore',
'jquery', 'jquery',
'text!templates/components/tabbed/tabbed_view.underscore', 'text!templates/components/tabbed/tabbed_view.underscore',
'text!templates/components/tabbed/tab.underscore'], 'text!templates/components/tabbed/tab.underscore',
function (Backbone, _, $, tabbedViewTemplate, tabTemplate) { 'text!templates/components/tabbed/tabpanel.underscore',
], function (
Backbone,
_,
$,
tabbedViewTemplate,
tabTemplate,
tabPanelTemplate
) {
var getTabPanelId = function (id) {
return 'tabpanel-' + id;
};
var TabPanelView = Backbone.View.extend({
template: _.template(tabPanelTemplate),
initialize: function (options) {
this.url = options.url;
this.view = options.view;
},
render: function () {
var tabPanelHtml = this.template({tabId: getTabPanelId(this.url)});
this.setElement($(tabPanelHtml));
this.$el.append(this.view.render().el);
return this;
}
});
var TabbedView = Backbone.View.extend({ var TabbedView = Backbone.View.extend({
events: { events: {
'click .nav-item[role="tab"]': 'switchTab' 'click .nav-item.tab': 'switchTab'
}, },
template: _.template(tabbedViewTemplate), template: _.template(tabbedViewTemplate),
...@@ -31,6 +57,10 @@ ...@@ -31,6 +57,10 @@
initialize: function (options) { initialize: function (options) {
this.router = options.router || null; this.router = options.router || null;
this.tabs = options.tabs; this.tabs = options.tabs;
// Convert each view into a TabPanelView
_.each(this.tabs, function (tabInfo) {
tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view});
}, this);
this.urlMap = _.reduce(this.tabs, function (map, value) { this.urlMap = _.reduce(this.tabs, function (map, value) {
map[value.url] = value; map[value.url] = value;
return map; return map;
...@@ -42,12 +72,17 @@ ...@@ -42,12 +72,17 @@
this.$el.html(this.template({})); this.$el.html(this.template({}));
_.each(this.tabs, function(tabInfo, index) { _.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate, { var tabEl = $(_.template(tabTemplate, {
index: index, index: index,
title: tabInfo.title, title: tabInfo.title,
url: tabInfo.url url: tabInfo.url,
})); tabPanelId: getTabPanelId(tabInfo.url)
})),
tabContainerEl = this.$('.tabs');
self.$('.page-content-nav').append(tabEl); self.$('.page-content-nav').append(tabEl);
});
// Render and append the current tab panel
tabContainerEl.append(tabInfo.view.render().$el);
}, this);
// Re-display the default (first) tab if the // Re-display the default (first) tab if the
// current route does not belong to one of the // current route does not belong to one of the
// tabs. Otherwise continue displaying the tab // tabs. Otherwise continue displaying the tab
...@@ -63,10 +98,16 @@ ...@@ -63,10 +98,16 @@
tab = tabMeta.tab, tab = tabMeta.tab,
tabEl = tabMeta.element, tabEl = tabMeta.element,
view = tab.view; view = tab.view;
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false'); // Hide old tab/tabpanel
tabEl.addClass('is-active').attr('aria-selected', 'true'); this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false');
view.setElement(this.$('.page-content-main')).render(); this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden');
this.$('.sr-is-focusable.sr-tab').focus(); // Show new tab/tabpanel
tabEl.addClass('is-active').attr('aria-expanded', 'true');
view.$el.attr('aria-expanded', 'true').removeClass('is-hidden');
// This bizarre workaround makes focus work in Chrome.
_.defer(function () {
view.$('.sr-is-focusable.' + getTabPanelId(tab.url)).focus();
});
if (this.router) { if (this.router) {
this.router.navigate(tab.url, {replace: true}); this.router.navigate(tab.url, {replace: true});
} }
...@@ -85,10 +126,10 @@ ...@@ -85,10 +126,10 @@
var tab, element; var tab, element;
if (typeof tabNameOrIndex === 'string') { if (typeof tabNameOrIndex === 'string') {
tab = this.urlMap[tabNameOrIndex]; tab = this.urlMap[tabNameOrIndex];
element = this.$('a[data-url='+tabNameOrIndex+']'); element = this.$('button[data-url='+tabNameOrIndex+']');
} else { } else {
tab = this.tabs[tabNameOrIndex]; tab = this.tabs[tabNameOrIndex];
element = this.$('a[data-index='+tabNameOrIndex+']'); element = this.$('button[data-index='+tabNameOrIndex+']');
} }
return {'tab': tab, 'element': element}; return {'tab': tab, 'element': element};
} }
......
...@@ -15,20 +15,40 @@ ...@@ -15,20 +15,40 @@
render: function () { render: function () {
this.$el.text(this.text); this.$el.text(this.text);
return this;
} }
}); }),
activeTab = function () {
return view.$('.page-content-nav');
},
activeTabPanel = function () {
return view.$('.tabpanel[aria-expanded="true"]');
};
describe('TabbedView component', function () { describe('TabbedView component', function () {
beforeEach(function () { beforeEach(function () {
view = new TabbedView({ view = new TabbedView({
tabs: [{ tabs: [{
title: 'Test 1', title: 'Test 1',
view: new TestSubview({text: 'this is test text'}) view: new TestSubview({text: 'this is test text'}),
url: 'test-1'
}, { }, {
title: 'Test 2', title: 'Test 2',
view: new TestSubview({text: 'other text'}) view: new TestSubview({text: 'other text'}),
url: 'test-2'
}] }]
}).render(); }).render();
// _.defer() is used to make calls to
// jQuery.focus() work in Chrome. _.defer()
// delays the execution of a function until the
// current call stack is clear. That behavior
// will cause tests to fail, so we'll instead
// make _.defer() immediately invoke its
// argument.
spyOn(_, 'defer').andCallFake(function (func) {
func();
});
}); });
it('can render itself', function () { it('can render itself', function () {
...@@ -36,33 +56,33 @@ ...@@ -36,33 +56,33 @@
}); });
it('shows its first tab by default', function () { it('shows its first tab by default', function () {
expect(view.$el.text()).toContain('this is test text'); expect(activeTabPanel().text()).toContain('this is test text');
expect(view.$el.text()).not.toContain('other text'); expect(activeTabPanel().text()).not.toContain('other text');
}); });
it('displays titles for each tab', function () { it('displays titles for each tab', function () {
expect(view.$el.text()).toContain('Test 1'); expect(activeTab().text()).toContain('Test 1');
expect(view.$el.text()).toContain('Test 2'); expect(activeTab().text()).toContain('Test 2');
}); });
it('can switch tabs', function () { it('can switch tabs', function () {
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-index=1]').click();
expect(view.$el.text()).not.toContain('this is test text'); expect(activeTabPanel().text()).not.toContain('this is test text');
expect(view.$el.text()).toContain('other text'); expect(activeTabPanel().text()).toContain('other text');
}); });
it('marks the active tab as selected using aria attributes', function () { it('marks the active tab as selected using aria attributes', function () {
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'true'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false');
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-index=1]').click();
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true');
}); });
it('sets focus for screen readers', function () { it('sets focus for screen readers', function () {
spyOn($.fn, 'focus'); spyOn($.fn, 'focus');
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-url="test-2"]').click();
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled(); expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled();
}); });
describe('history', function() { describe('history', function() {
......
...@@ -275,6 +275,14 @@ ...@@ -275,6 +275,14 @@
} }
} }
.members-info {
margin: 0;
padding: 0;
li {
display: inline;
}
}
.edit-members { .edit-members {
@extend %ui-no-list; @extend %ui-no-list;
...@@ -341,3 +349,23 @@ ...@@ -341,3 +349,23 @@
} }
} }
//efischer - TNL-3189
//copied from cms/static/sass/elements/_system-feedback.scss#L106
//along with some "hide the inherited value, we want none" action
.prompt.warning button {
@extend %btn-no-style;
box-shadow: none;
text-shadow: none;
&:hover {
color: $orange-s2;
background: transparent;
box-shadow: none;
}
&:focus {
box-shadow: none;
border: 0px;
}
}
...@@ -22,9 +22,12 @@ ...@@ -22,9 +22,12 @@
%button-reset { %button-reset {
box-shadow: none; box-shadow: none;
border: none; border: none;
border-radius: 0;
background-image: none; background-image: none;
background-color: transparent; background-color: transparent;
font-weight: normal; font-family: inherit;
font-size: inherit;
font-weight: inherit;
} }
// layout // layout
...@@ -148,6 +151,7 @@ ...@@ -148,6 +151,7 @@
border-bottom: 3px solid $gray-l5; border-bottom: 3px solid $gray-l5;
.nav-item { .nav-item {
@extend %button-reset;
display: inline-block; display: inline-block;
margin-bottom: -3px; // to match the border margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5; border-bottom: 3px solid $gray-l5;
...@@ -175,7 +179,7 @@ ...@@ -175,7 +179,7 @@
.listing-tools { .listing-tools {
@extend %t-copy-sub1; @extend %t-copy-sub1;
margin: ($baseline/10) $baseline; margin: ($baseline/10) $baseline;
color: $gray-l1; color: $gray-d1;
label { // override label { // override
color: inherit; color: inherit;
...@@ -719,11 +723,11 @@ ...@@ -719,11 +723,11 @@
.u-field.error { .u-field.error {
input, textarea { input, textarea {
border-color: $error-red; border-color: $danger-red;
} }
.u-field-message-help, .u-field-description-message { .u-field-message-help, .u-field-description-message {
color: $error-red !important; color: $danger-red !important;
} }
} }
...@@ -748,5 +752,3 @@ ...@@ -748,5 +752,3 @@
.create-team.form-actions { .create-team.form-actions {
margin-top: $baseline; margin-top: $baseline;
} }
<a class="nav-item" href="" data-url="<%= url %>" data-index="<%= index %>" role="tab" aria-selected="false"><%= title %></a> <button class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" is-active="false" aria-expanded="false" aria-controls="<%= tabPanelId %>"><%= title %></button>
<nav class="page-content-nav" aria-label="Teams"></nav> <nav class="page-content-nav" aria-label="Teams"></nav>
<div class="sr-is-focusable sr-tab" tabindex="-1"></div> <div class="page-content-main">
<div class="page-content-main"></div> <div class="tabs"></div>
</div>
<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false">
<div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div>
</div>
...@@ -59,7 +59,7 @@ else: ...@@ -59,7 +59,7 @@ else:
<a class="btn" href="${cert_status['cert_web_view_url']}" target="_blank" <a class="btn" href="${cert_status['cert_web_view_url']}" target="_blank"
title="${_('This link will open the certificate web view')}"> title="${_('This link will open the certificate web view')}">
${_("View {cert_name_short}").format(cert_name_short=cert_name_short,)}</a></li> ${_("View {cert_name_short}").format(cert_name_short=cert_name_short,)}</a></li>
% elif cert_status['show_download_url'] and (enrollment.mode == 'honor' or enrollment.mode == 'audit'): % elif cert_status['show_download_url'] and enrollment.mode in CourseMode.NON_VERIFIED_MODES:
<li class="action action-certificate"> <li class="action action-certificate">
<a class="btn" href="${cert_status['download_url']}" <a class="btn" href="${cert_status['download_url']}"
title="${_('This link will open/download a PDF document')}"> title="${_('This link will open/download a PDF document')}">
......
...@@ -48,7 +48,7 @@ git+https://github.com/edx/i18n-tools.git@v0.1.3#egg=i18n-tools==v0.1.3 ...@@ -48,7 +48,7 @@ git+https://github.com/edx/i18n-tools.git@v0.1.3#egg=i18n-tools==v0.1.3
git+https://github.com/edx/edx-oauth2-provider.git@0.5.6#egg=oauth2-provider==0.5.6 git+https://github.com/edx/edx-oauth2-provider.git@0.5.6#egg=oauth2-provider==0.5.6
-e git+https://github.com/edx/edx-val.git@v0.0.5#egg=edx-val -e git+https://github.com/edx/edx-val.git@v0.0.5#egg=edx-val
-e git+https://github.com/pmitros/RecommenderXBlock.git@518234bc354edbfc2651b9e534ddb54f96080779#egg=recommender-xblock -e git+https://github.com/pmitros/RecommenderXBlock.git@518234bc354edbfc2651b9e534ddb54f96080779#egg=recommender-xblock
-e git+https://github.com/edx/edx-search.git@release-2015-09-04#egg=edx-search -e git+https://github.com/edx/edx-search.git@release-2015-09-11a#egg=edx-search
-e git+https://github.com/edx/edx-milestones.git@9b44a37edc3d63a23823c21a63cdd53ef47a7aa4#egg=edx-milestones -e git+https://github.com/edx/edx-milestones.git@9b44a37edc3d63a23823c21a63cdd53ef47a7aa4#egg=edx-milestones
git+https://github.com/edx/edx-lint.git@c5745631d2eee4e2efe8c31fa7b42fe2c12a0755#egg=edx_lint==0.2.7 git+https://github.com/edx/edx-lint.git@c5745631d2eee4e2efe8c31fa7b42fe2c12a0755#egg=edx_lint==0.2.7
-e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils -e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils
......
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