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) {
'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();
},
......
<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):
"""
Assert that each item in the expected events sequence matches its counterpart at the same index in the actual
events sequence.
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).
"""
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_type': 'edx.team.page_viewed',
'event': {
'search_text': search_text,
'page_name': 'search-teams',
'topic_id': self.topic['id'],
'number_of_results': 0
'team_id': None
}
}, {
'event_type': 'edx.team.page_viewed',
'event_type': 'edx.team.searched',
'event': {
'page_name': 'search-teams',
'search_text': search_text,
'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)
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'))
......
# -*- 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 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"""
"""An attempt was made to change an immutable field on a CourseTeamMembership model."""
pass
""" 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
));
};
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();
teamsTabView = createTeamsTabView(),
requestCountBeforeSearch;
teamsTabView.browseTopic(TeamSpecHelpers.testTopicID);
verifyTeamsRequest(requests, {
order_by: 'last_activity_at',
text_search: ''
});
AjaxHelpers.respondWithJson(requests, {});
teamsTabView.$('.search-field').val('foo');
teamsTabView.$('.action-search').click();
verifyTeamsRequest(requests, {
order_by: '',
text_search: 'foo'
});
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('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),
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 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) {
......
<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;
}
<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>
<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>
<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
......
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