Merge remote-tracking branch 'origin/release' into jeskew/resolve_issues_with_release
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
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) {
|
||||
'title': gettext('Signatory title should span over maximum of 2 lines.')
|
||||
}, errors);
|
||||
}
|
||||
else if ((lines.length > 1 && (lines[0].length > 40 || lines[1].length > 40)) ||
|
||||
(lines.length === 1 && title.length > 40)) {
|
||||
else if ((lines.length > 1 && (lines[0].length > 53 && lines[1].length > 53)) ||
|
||||
(lines.length === 1 && title.length > 106)) {
|
||||
errors = _.extend({
|
||||
'title': gettext('Signatory title should have maximum of 40 characters per line.')
|
||||
}, errors);
|
||||
|
||||
@@ -246,7 +246,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
|
||||
});
|
||||
|
||||
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, {
|
||||
|
||||
@@ -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();
|
||||
setValuesToInputs(this.view, {
|
||||
inputCertificateName: 'New Certificate Name'
|
||||
@@ -239,7 +239,7 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce
|
||||
});
|
||||
|
||||
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, {
|
||||
|
||||
@@ -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 () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
|
||||
@@ -6,8 +6,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "common/js/compo
|
||||
function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) {
|
||||
var AddXBlockComponent = BaseView.extend({
|
||||
events: {
|
||||
'click .new-component .new-component-type button.multiple-templates': 'showComponentTemplates',
|
||||
'click .new-component .new-component-type button.single-template': 'createNewComponent',
|
||||
'click .new-component .new-component-type .multiple-templates': 'showComponentTemplates',
|
||||
'click .new-component .new-component-type .single-template': 'createNewComponent',
|
||||
'click .new-component .cancel-button': 'closeNewComponent',
|
||||
'click .new-component-templates .new-component-template .button-component': 'createNewComponent',
|
||||
'click .new-component-templates .cancel-button': 'closeNewComponent'
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
</div>
|
||||
<div class="input-wrap field text add-signatory-title <% if(error && error.title) { print('error'); } %>">
|
||||
<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>
|
||||
<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>
|
||||
<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 of 106 characters.") %></span>
|
||||
<% if(error && error.title) { %>
|
||||
<span class="message-error"><%= error.title %></span>
|
||||
<% } %>
|
||||
@@ -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>
|
||||
</div>
|
||||
<button type="button" class="action action-upload-signature">Upload Signature Image</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,9 @@ class CourseMode(models.Model):
|
||||
# Modes that allow a student to pursue a verified certificate
|
||||
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
|
||||
CREDIT_MODES = [CREDIT_MODE]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from certificates.tests.factories import GeneratedCertificateFactory # 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
|
||||
|
||||
@@ -42,6 +43,15 @@ class CertificateDisplayTest(ModuleStoreTestCase):
|
||||
self._create_certificate(enrollment_mode)
|
||||
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')
|
||||
@override_settings(CERT_NAME_SHORT='Test_Certificate')
|
||||
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
|
||||
@@ -105,6 +115,16 @@ class CertificateDisplayTest(ModuleStoreTestCase):
|
||||
self.assertContains(response, u'Download Your ID Verified')
|
||||
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):
|
||||
"""
|
||||
Make sure response does not have any of the download certificate buttons
|
||||
|
||||
@@ -97,13 +97,10 @@ class DiscussionModule(DiscussionFields, XModule):
|
||||
|
||||
def get_course(self):
|
||||
"""
|
||||
Return the CourseDescriptor at the root of the tree we're in.
|
||||
Return CourseDescriptor by course id.
|
||||
"""
|
||||
block = self
|
||||
while block.parent:
|
||||
block = block.get_parent()
|
||||
|
||||
return block
|
||||
course = self.runtime.modulestore.get_course(self.course_id)
|
||||
return course
|
||||
|
||||
|
||||
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
|
||||
|
||||
@@ -312,7 +312,7 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
|
||||
if bulk_write_record.active:
|
||||
bulk_write_record.index = updated_index_entry
|
||||
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):
|
||||
bulk_write_record = self._get_bulk_ops_record(course_key)
|
||||
|
||||
@@ -90,16 +90,20 @@
|
||||
*/
|
||||
setPage: function (page) {
|
||||
var oldPage = this.currentPage,
|
||||
self = this;
|
||||
return this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
|
||||
self = this,
|
||||
deferred = $.Deferred();
|
||||
this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
|
||||
function () {
|
||||
self.isStale = false;
|
||||
self.trigger('page_changed');
|
||||
deferred.resolve();
|
||||
},
|
||||
function () {
|
||||
self.currentPage = oldPage;
|
||||
deferred.fail();
|
||||
}
|
||||
);
|
||||
return deferred.promise();
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="page-header-search wrapper-search-<%= type %>">
|
||||
<form class="search-form">
|
||||
<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 %>" />
|
||||
<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>
|
||||
|
||||
@@ -13,8 +13,8 @@ from .fields import FieldsMixin
|
||||
|
||||
TOPIC_CARD_CSS = 'div.wrapper-card-core'
|
||||
CARD_TITLE_CSS = 'h3.card-title'
|
||||
MY_TEAMS_BUTTON_CSS = 'a.nav-item[data-index="0"]'
|
||||
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]'
|
||||
MY_TEAMS_BUTTON_CSS = '.nav-item[data-index="0"]'
|
||||
BROWSE_BUTTON_CSS = '.nav-item[data-index="1"]'
|
||||
TEAMS_LINK_CSS = '.action-view'
|
||||
TEAMS_HEADER_CSS = '.teams-header'
|
||||
CREATE_TEAM_LINK_CSS = '.create-team'
|
||||
@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team'
|
||||
class TeamCardsMixin(object):
|
||||
"""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):
|
||||
"""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
|
||||
def team_cards(self):
|
||||
"""Get all the team cards on the page."""
|
||||
return self.q(css='.team-card')
|
||||
return self.q(css=self._bounded_selector('.team-card'))
|
||||
|
||||
@property
|
||||
def team_names(self):
|
||||
"""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
|
||||
def team_descriptions(self):
|
||||
"""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):
|
||||
@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
|
||||
"""
|
||||
|
||||
url_path = "teams/#my-teams"
|
||||
tabpanel_id = '#tabpanel-my-teams'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the "My Teams" tab is being viewed."""
|
||||
@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
|
||||
@property
|
||||
def topic_names(self):
|
||||
"""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
|
||||
def topic_descriptions(self):
|
||||
@@ -508,7 +513,7 @@ class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin):
|
||||
|
||||
def click_first_profile_image(self):
|
||||
"""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
|
||||
def first_member_username(self):
|
||||
|
||||
@@ -334,7 +334,7 @@ class EventsTestMixin(TestCase):
|
||||
captured_events.append(event)
|
||||
|
||||
@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.
|
||||
|
||||
@@ -351,7 +351,7 @@ class EventsTestMixin(TestCase):
|
||||
with self.capture_events(event_filter, len(expected_events), captured_events):
|
||||
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):
|
||||
"""
|
||||
@@ -477,17 +477,29 @@ class EventsTestMixin(TestCase):
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
"""
|
||||
Assert that each item in the expected events sequence matches its counterpart at the same index in the actual
|
||||
events sequence.
|
||||
"""
|
||||
for expected_event, actual_event in zip(expected_events, actual_events):
|
||||
assert_event_matches(
|
||||
expected_event,
|
||||
actual_event,
|
||||
tolerate=EventMatchTolerates.lenient()
|
||||
)
|
||||
if in_order:
|
||||
for expected_event, actual_event in zip(expected_events, actual_events):
|
||||
assert_event_matches(
|
||||
expected_event,
|
||||
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):
|
||||
"""Return an aboslute URI given a relative path taking into account the test context."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from dateutil.parser import parse
|
||||
import ddt
|
||||
from nose.plugins.attrib import attr
|
||||
from uuid import uuid4
|
||||
from unittest import skip
|
||||
|
||||
from ..helpers import EventsTestMixin, UniqueCourseTest
|
||||
from ...fixtures import LMS_BASE_URL
|
||||
@@ -783,7 +782,6 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
|
||||
self.browse_teams_page.click_browse_all_teams_link()
|
||||
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):
|
||||
"""
|
||||
Scenario: User should be able to search for a team
|
||||
@@ -794,6 +792,7 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
|
||||
And the search header should be shown
|
||||
And 0 results should be shown
|
||||
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
|
||||
# used by Bok Choy.
|
||||
@@ -801,21 +800,21 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
|
||||
self.create_teams(self.topic, 5)
|
||||
self.browse_teams_page.visit()
|
||||
events = [{
|
||||
'event_type': 'edx.team.searched',
|
||||
'event': {
|
||||
'search_text': search_text,
|
||||
'topic_id': self.topic['id'],
|
||||
'number_of_results': 0
|
||||
}
|
||||
}, {
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'search-teams',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': None
|
||||
}
|
||||
}, {
|
||||
'event_type': 'edx.team.searched',
|
||||
'event': {
|
||||
'search_text': search_text,
|
||||
'topic_id': self.topic['id'],
|
||||
'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)
|
||||
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'))
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test for Discussion Xmodule functional logic."""
|
||||
import ddt
|
||||
from mock import Mock
|
||||
from . import BaseTestXmodule
|
||||
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):
|
||||
"""Logic tests for Discussion Xmodule."""
|
||||
CATEGORY = "discussion"
|
||||
@@ -24,3 +30,63 @@ class DiscussionModuleTest(BaseTestXmodule):
|
||||
html = fragment.content
|
||||
self.assertIn('data-user-create-comment="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):
|
||||
pass
|
||||
|
||||
|
||||
class ImmutableMembershipFieldException(Exception):
|
||||
"""An attempt was made to change an immutable field on a CourseTeamMembership model"""
|
||||
class ElasticSearchConnectionError(TeamAPIRequestError):
|
||||
"""The system was unable to connect to the configured elasticsearch instance."""
|
||||
pass
|
||||
|
||||
|
||||
class ImmutableMembershipFieldException(Exception):
|
||||
"""An attempt was made to change an immutable field on a CourseTeamMembership model."""
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
""" Search index used to load data into elasticsearch"""
|
||||
|
||||
import logging
|
||||
from elasticsearch.exceptions import ConnectionError
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
@@ -8,6 +11,7 @@ from functools import wraps
|
||||
|
||||
from search.search_engine_base import SearchEngine
|
||||
|
||||
from .errors import ElasticSearchConnectionError
|
||||
from .serializers import CourseTeamSerializer, CourseTeam
|
||||
|
||||
|
||||
@@ -103,8 +107,11 @@ class CourseTeamIndexer(object):
|
||||
"""
|
||||
Return course team search engine (if feature is enabled).
|
||||
"""
|
||||
if cls.search_is_enabled():
|
||||
try:
|
||||
return SearchEngine.get_search_engine(index=cls.INDEX_NAME)
|
||||
except ConnectionError as err:
|
||||
logging.error('Error connecting to elasticsearch: %s', err)
|
||||
raise ElasticSearchConnectionError
|
||||
|
||||
@classmethod
|
||||
def search_is_enabled(cls):
|
||||
@@ -119,7 +126,10 @@ def course_team_post_save_callback(**kwargs):
|
||||
"""
|
||||
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')
|
||||
@@ -127,4 +137,7 @@ def course_team_post_delete_callback(**kwargs): # pylint: disable=invalid-name
|
||||
"""
|
||||
Reindex object after delete.
|
||||
"""
|
||||
CourseTeamIndexer.remove(kwargs['instance'])
|
||||
try:
|
||||
CourseTeamIndexer.remove(kwargs['instance'])
|
||||
except ElasticSearchConnectionError:
|
||||
pass
|
||||
|
||||
@@ -24,7 +24,9 @@ define([
|
||||
it('can render itself', function () {
|
||||
var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5),
|
||||
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');
|
||||
|
||||
@@ -29,19 +29,6 @@ define([
|
||||
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 () {
|
||||
setFixtures('<div class="teams-content"></div>');
|
||||
spyOn($.fn, 'focus');
|
||||
@@ -233,25 +220,33 @@ define([
|
||||
options
|
||||
));
|
||||
};
|
||||
|
||||
it('can search teams', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
teamsTabView = createTeamsTabView();
|
||||
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
|
||||
verifyTeamsRequest(requests, {
|
||||
order_by: 'last_activity_at',
|
||||
text_search: ''
|
||||
});
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
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 () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
teamsTabView = createTeamsTabView(),
|
||||
requestCountBeforeSearch;
|
||||
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
|
||||
verifyTeamsRequest(requests, {
|
||||
order_by: 'last_activity_at',
|
||||
text_search: ''
|
||||
});
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
requestCountBeforeSearch = requests.length;
|
||||
performSearch(requests, teamsTabView);
|
||||
expect(teamsTabView.$('.page-title').text()).toBe('Team Search');
|
||||
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 () {
|
||||
@@ -259,17 +254,10 @@ define([
|
||||
teamsTabView = createTeamsTabView();
|
||||
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
performSearch(requests, teamsTabView);
|
||||
|
||||
// Perform a search
|
||||
teamsTabView.$('.search-field').val('foo');
|
||||
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, {});
|
||||
performSearch(requests, teamsTabView);
|
||||
|
||||
// Clear the search and submit it again
|
||||
teamsTabView.$('.search-field').val('');
|
||||
@@ -283,16 +271,39 @@ define([
|
||||
expect(teamsTabView.$('.page-description').text()).toBe('Test description 1');
|
||||
});
|
||||
|
||||
it('can navigate back to all teams from a search', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
teamsTabView = createTeamsTabView();
|
||||
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
|
||||
// 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
|
||||
// Perform a search but respond with a 500
|
||||
teamsTabView.$('.search-field').val('foo');
|
||||
teamsTabView.$('.action-search').click();
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
|
||||
// Verify that the team list is still shown
|
||||
expect(teamsTabView.$('.page-title').text()).toBe('Test Topic 1');
|
||||
expect(teamsTabView.$('.page-description').text()).toBe('Test description 1');
|
||||
expect(teamsTabView.$('.search-field').val(), 'foo');
|
||||
|
||||
@@ -44,7 +44,9 @@ define([
|
||||
it('can render itself', function () {
|
||||
var testTeamData = TeamSpecHelpers.createMockTeamData(1, 5),
|
||||
teamsView = createTopicTeamsView({
|
||||
teams: TeamSpecHelpers.createMockTeams(testTeamData),
|
||||
teams: TeamSpecHelpers.createMockTeams({
|
||||
results: testTeamData
|
||||
}),
|
||||
teamMemberships: TeamSpecHelpers.createMockTeamMemberships([])
|
||||
});
|
||||
|
||||
|
||||
@@ -43,18 +43,22 @@ define([
|
||||
});
|
||||
};
|
||||
|
||||
var createMockTeams = function(teamData) {
|
||||
if (!teamData) {
|
||||
teamData = createMockTeamData(1, 5);
|
||||
}
|
||||
return new TeamCollection(
|
||||
var createMockTeamsResponse = function(options) {
|
||||
return _.extend(
|
||||
{
|
||||
count: 6,
|
||||
num_pages: 2,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
results: teamData
|
||||
results: createMockTeamData(1, 5)
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
var createMockTeams = function(options) {
|
||||
return new TeamCollection(
|
||||
createMockTeamsResponse(options),
|
||||
{
|
||||
teamEvents: teamEvents,
|
||||
course_id: testCourseID,
|
||||
@@ -325,6 +329,7 @@ define([
|
||||
testTeamDiscussionID: testTeamDiscussionID,
|
||||
testContext: testContext,
|
||||
createMockTeamData: createMockTeamData,
|
||||
createMockTeamsResponse: createMockTeamsResponse,
|
||||
createMockTeams: createMockTeams,
|
||||
createMockTeamMembershipsData: createMockTeamMembershipsData,
|
||||
createMockTeamMemberships: createMockTeamMemberships,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) {
|
||||
var TeamsHeaderModel = HeaderModel.extend({
|
||||
initialize: function () {
|
||||
_.extend(this.defaults, {nav_aria_label: gettext('teams')});
|
||||
_.extend(this.defaults, {nav_aria_label: gettext('Topics')});
|
||||
HeaderModel.prototype.initialize.call(this);
|
||||
}
|
||||
});
|
||||
@@ -214,6 +214,7 @@
|
||||
view.mainView = view.createTeamsListView({
|
||||
topic: topic,
|
||||
collection: view.teamsCollection,
|
||||
breadcrumbs: view.createBreadcrumbs(topic),
|
||||
title: gettext('Team Search'),
|
||||
description: interpolate(
|
||||
gettext('Showing results for "%(searchString)s"'),
|
||||
@@ -337,6 +338,7 @@
|
||||
var teamsView = view.createTeamsListView({
|
||||
topic: topic,
|
||||
collection: collection,
|
||||
breadcrumbs: view.createBreadcrumbs(),
|
||||
showSortControls: true
|
||||
});
|
||||
deferred.resolve(teamsView);
|
||||
@@ -368,7 +370,7 @@
|
||||
headerActionsView: searchFieldView,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
breadcrumbs: this.createBreadcrumbs()
|
||||
breadcrumbs: options.breadcrumbs
|
||||
}),
|
||||
searchUrl = 'topics/' + topic.get('id') + '/search';
|
||||
// Listen to requests to sync the collection and redirect it as follows:
|
||||
@@ -378,6 +380,11 @@
|
||||
// 3. Otherwise, do nothing and remain on the current page.
|
||||
// Note: Backbone makes this a no-op if redirecting to the current page.
|
||||
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) {
|
||||
Backbone.history.navigate(searchUrl, {trigger: true});
|
||||
} else if (Backbone.history.getFragment() === searchUrl) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<span class="team-member">
|
||||
<li>
|
||||
<span class="team-member">
|
||||
<a class="member-profile" href="<%= memberProfileUrl %>">
|
||||
<p class="tooltip-custom"><%= username %></p>
|
||||
<img class="image-url" src="<%= imageUrl %>" alt="profile page" />
|
||||
<p class="tooltip-custom"><%= username %></p>
|
||||
<img class="image-url" src="<%= imageUrl %>" alt="profile page" />
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<% if (hasMembers) { %>
|
||||
<span class="sr"><%- gettext("Team member profiles") %></span>
|
||||
<% } %>
|
||||
<div class="members-info"></div>
|
||||
<ul class="members-info"></ul>
|
||||
</div>
|
||||
|
||||
<div class="team-capacity">
|
||||
|
||||
@@ -5,6 +5,9 @@ import pytz
|
||||
from datetime import datetime
|
||||
from dateutil import parser
|
||||
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.conf import settings
|
||||
@@ -1397,3 +1400,49 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase):
|
||||
|
||||
def test_missing_membership(self):
|
||||
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 (
|
||||
add_team_count
|
||||
)
|
||||
from .search_indexes import CourseTeamIndexer
|
||||
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
|
||||
from .errors import AlreadyOnTeamInCourse, ElasticSearchConnectionError, NotEnrolledInCourseForTeam
|
||||
|
||||
TEAM_MEMBERSHIPS_PER_PAGE = 2
|
||||
TOPICS_PER_PAGE = 12
|
||||
@@ -293,6 +293,9 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
example, the course_id may not reference a real course or the 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**
|
||||
|
||||
Any logged in user who has verified their email address can create
|
||||
@@ -366,7 +369,14 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||
result_filter.update({'topic_id': topic_id})
|
||||
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})
|
||||
|
||||
search_results = search_engine.search(
|
||||
|
||||
@@ -4,11 +4,37 @@
|
||||
'underscore',
|
||||
'jquery',
|
||||
'text!templates/components/tabbed/tabbed_view.underscore',
|
||||
'text!templates/components/tabbed/tab.underscore'],
|
||||
function (Backbone, _, $, tabbedViewTemplate, tabTemplate) {
|
||||
'text!templates/components/tabbed/tab.underscore',
|
||||
'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({
|
||||
events: {
|
||||
'click .nav-item[role="tab"]': 'switchTab'
|
||||
'click .nav-item.tab': 'switchTab'
|
||||
},
|
||||
|
||||
template: _.template(tabbedViewTemplate),
|
||||
@@ -31,6 +57,10 @@
|
||||
initialize: function (options) {
|
||||
this.router = options.router || null;
|
||||
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) {
|
||||
map[value.url] = value;
|
||||
return map;
|
||||
@@ -42,12 +72,17 @@
|
||||
this.$el.html(this.template({}));
|
||||
_.each(this.tabs, function(tabInfo, index) {
|
||||
var tabEl = $(_.template(tabTemplate, {
|
||||
index: index,
|
||||
title: tabInfo.title,
|
||||
url: tabInfo.url
|
||||
}));
|
||||
index: index,
|
||||
title: tabInfo.title,
|
||||
url: tabInfo.url,
|
||||
tabPanelId: getTabPanelId(tabInfo.url)
|
||||
})),
|
||||
tabContainerEl = this.$('.tabs');
|
||||
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
|
||||
// current route does not belong to one of the
|
||||
// tabs. Otherwise continue displaying the tab
|
||||
@@ -63,10 +98,16 @@
|
||||
tab = tabMeta.tab,
|
||||
tabEl = tabMeta.element,
|
||||
view = tab.view;
|
||||
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false');
|
||||
tabEl.addClass('is-active').attr('aria-selected', 'true');
|
||||
view.setElement(this.$('.page-content-main')).render();
|
||||
this.$('.sr-is-focusable.sr-tab').focus();
|
||||
// Hide old tab/tabpanel
|
||||
this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false');
|
||||
this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden');
|
||||
// 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) {
|
||||
this.router.navigate(tab.url, {replace: true});
|
||||
}
|
||||
@@ -85,10 +126,10 @@
|
||||
var tab, element;
|
||||
if (typeof tabNameOrIndex === 'string') {
|
||||
tab = this.urlMap[tabNameOrIndex];
|
||||
element = this.$('a[data-url='+tabNameOrIndex+']');
|
||||
element = this.$('button[data-url='+tabNameOrIndex+']');
|
||||
} else {
|
||||
tab = this.tabs[tabNameOrIndex];
|
||||
element = this.$('a[data-index='+tabNameOrIndex+']');
|
||||
element = this.$('button[data-index='+tabNameOrIndex+']');
|
||||
}
|
||||
return {'tab': tab, 'element': element};
|
||||
}
|
||||
|
||||
@@ -15,20 +15,40 @@
|
||||
|
||||
render: function () {
|
||||
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 () {
|
||||
beforeEach(function () {
|
||||
view = new TabbedView({
|
||||
tabs: [{
|
||||
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',
|
||||
view: new TestSubview({text: 'other text'})
|
||||
view: new TestSubview({text: 'other text'}),
|
||||
url: 'test-2'
|
||||
}]
|
||||
}).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 () {
|
||||
@@ -36,33 +56,33 @@
|
||||
});
|
||||
|
||||
it('shows its first tab by default', function () {
|
||||
expect(view.$el.text()).toContain('this is test text');
|
||||
expect(view.$el.text()).not.toContain('other text');
|
||||
expect(activeTabPanel().text()).toContain('this is test text');
|
||||
expect(activeTabPanel().text()).not.toContain('other text');
|
||||
});
|
||||
|
||||
it('displays titles for each tab', function () {
|
||||
expect(view.$el.text()).toContain('Test 1');
|
||||
expect(view.$el.text()).toContain('Test 2');
|
||||
expect(activeTab().text()).toContain('Test 1');
|
||||
expect(activeTab().text()).toContain('Test 2');
|
||||
});
|
||||
|
||||
it('can switch tabs', function () {
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$el.text()).not.toContain('this is test text');
|
||||
expect(view.$el.text()).toContain('other text');
|
||||
expect(activeTabPanel().text()).not.toContain('this is test text');
|
||||
expect(activeTabPanel().text()).toContain('other text');
|
||||
});
|
||||
|
||||
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=1]')).toHaveAttr('aria-selected', 'false');
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false');
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'false');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true');
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('sets focus for screen readers', function () {
|
||||
spyOn($.fn, 'focus');
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled();
|
||||
view.$('.nav-item[data-url="test-2"]').click();
|
||||
expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('history', function() {
|
||||
|
||||
@@ -275,6 +275,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.members-info {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
li {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-members {
|
||||
@extend %ui-no-list;
|
||||
|
||||
@@ -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 @@
|
||||
%button-reset {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
// layout
|
||||
@@ -148,6 +151,7 @@
|
||||
border-bottom: 3px solid $gray-l5;
|
||||
|
||||
.nav-item {
|
||||
@extend %button-reset;
|
||||
display: inline-block;
|
||||
margin-bottom: -3px; // to match the border
|
||||
border-bottom: 3px solid $gray-l5;
|
||||
@@ -175,7 +179,7 @@
|
||||
.listing-tools {
|
||||
@extend %t-copy-sub1;
|
||||
margin: ($baseline/10) $baseline;
|
||||
color: $gray-l1;
|
||||
color: $gray-d1;
|
||||
|
||||
label { // override
|
||||
color: inherit;
|
||||
@@ -719,11 +723,11 @@
|
||||
|
||||
.u-field.error {
|
||||
input, textarea {
|
||||
border-color: $error-red;
|
||||
border-color: $danger-red;
|
||||
}
|
||||
|
||||
.u-field-message-help, .u-field-description-message {
|
||||
color: $error-red !important;
|
||||
color: $danger-red !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,5 +752,3 @@
|
||||
.create-team.form-actions {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<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>
|
||||
<div class="page-content-main">
|
||||
<div class="tabs"></div>
|
||||
</div>
|
||||
|
||||
3
lms/templates/components/tabbed/tabpanel.underscore
Normal file
3
lms/templates/components/tabbed/tabpanel.underscore
Normal file
@@ -0,0 +1,3 @@
|
||||
<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:
|
||||
<a class="btn" href="${cert_status['cert_web_view_url']}" target="_blank"
|
||||
title="${_('This link will open the certificate web view')}">
|
||||
${_("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">
|
||||
<a class="btn" href="${cert_status['download_url']}"
|
||||
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
|
||||
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/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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user