Commit c7153be0 by polesye Committed by Tim Babych

TNL-213: Let Students Add Personal Notes to Course Content.

Co-Authored-By: Jean-Michel Claus <jmc@edx.org>
Co-Authored-By: Brian Talbot <btalbot@edx.org>
Co-Authored-By: Tim Babych <tim@edx.org>
Co-Authored-By: Oleg Marshev <oleg@edx.org>
Co-Authored-By: Chris Rodriguez <crodriguez@edx.org>
parent c11a9f05
......@@ -5,6 +5,28 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
LMS: Student Notes: Eventing for Student Notes. TNL-931
LMS: Student Notes: Add course structure view. TNL-762
LMS: Student Notes: Scroll and opening of notes. TNL-784
LMS: Student Notes: Add styling to Notes page. TNL-932
LMS: Student Notes: Add more graceful error message.
LMS: Student Notes: Toggle all notes TNL-661
LMS: Student Notes: Use JWT ID-Token for authentication annotation requests. TNL-782
LMS: Student Notes: Add possibility to search notes. TNL-731
LMS: Student Notes: Toggle single note visibility. TNL-660
LMS: Student Notes: Add Notes page. TNL-797
LMS: Student Notes: Add possibility to add/edit/remove notes. TNL-655
Platform: Add group_access field to all xblocks. TNL-670
LMS: Add support for user partitioning based on cohort. TNL-710
......
......@@ -552,6 +552,80 @@ class CourseMetadataEditingTest(CourseTestCase):
)
self.assertNotIn('giturl', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_edxnotes_present(self):
"""
If feature flag ENABLE_EDXNOTES is on, show the setting as a non-deprecated Advanced Setting.
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_edxnotes_not_present(self):
"""
If feature flag ENABLE_EDXNOTES is off, don't show the setting at all on the Advanced Settings page.
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertNotIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_validate_update_filtered_edxnotes_off(self):
"""
If feature flag is off, then edxnotes must be filtered.
"""
# pylint: disable=unused-variable
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertNotIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_validate_update_filtered_edxnotes_on(self):
"""
If feature flag is on, then edxnotes must not be filtered.
"""
# pylint: disable=unused-variable
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_update_from_json_filtered_edxnotes_on(self):
"""
If feature flag is on, then edxnotes must be updated.
"""
test_model = CourseMetadata.update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_update_from_json_filtered_edxnotes_off(self):
"""
If feature flag is off, then edxnotes must not be updated.
"""
test_model = CourseMetadata.update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertNotIn('edxnotes', test_model)
def test_validate_and_update_from_json_correct_inputs(self):
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
......@@ -711,6 +785,23 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_course_settings_munge_tabs(self):
"""
Test that adding and removing specific course settings adds and removes tabs.
"""
self.assertNotIn(EXTRA_TAB_PANELS.get("edxnotes"), self.course.tabs)
self.client.ajax_post(self.course_setting_url, {
"edxnotes": {"value": True}
})
course = modulestore().get_course(self.course.id)
self.assertIn(EXTRA_TAB_PANELS.get("edxnotes"), course.tabs)
self.client.ajax_post(self.course_setting_url, {
"edxnotes": {"value": False}
})
course = modulestore().get_course(self.course.id)
self.assertNotIn(EXTRA_TAB_PANELS.get("edxnotes"), course.tabs)
class CourseGraderUpdatesTest(CourseTestCase):
"""
......
......@@ -30,7 +30,8 @@ log = logging.getLogger(__name__)
# In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name": _("Open Ended Panel"), "type": "open_ended"}
NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
EDXNOTES_PANEL = {"name": _("Notes"), "type": "edxnotes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL, EDXNOTES_PANEL]])
def add_instructor(course_key, requesting_user, new_instructor):
......
......@@ -867,62 +867,100 @@ def grading_handler(request, course_key_string, grader_index=None):
# pylint: disable=invalid-name
def _config_course_advanced_components(request, course_module):
"""
Check to see if the user instantiated any advanced components. This
is a hack that does the following :
1) adds/removes the open ended panel tab to a course automatically
if the user has indicated that they want to edit the
combinedopendended or peergrading module
2) adds/removes the notes panel tab to a course automatically if
the user has indicated that they want the notes module enabled in
their course
"""
# TODO refactor the above into distinct advanced policy settings
filter_tabs = True # Exceptional conditions will pull this to False
if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components
tab_component_map = {
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
'notes': NOTE_COMPONENT_TYPES,
}
# Check to see if the user instantiated any notes or open ended components
for tab_type in tab_component_map.keys():
component_types = tab_component_map.get(tab_type)
found_ac_type = False
for ac_type in component_types:
# Check if the user has incorrectly failed to put the value in an iterable.
new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value']
if hasattr(new_advanced_component_list, '__iter__'):
if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES:
# Add tab to the course if needed
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
# If a tab has been added to the course, then send the
# metadata along to CourseMetadata.update_from_json
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should not be filtered out of
# the metadata
filter_tabs = False # Set this flag to avoid the tab removal code below.
found_ac_type = True # break
else:
# If not iterable, return immediately and let validation handle.
return
def _add_tab(request, tab_type, course_module):
"""
Adds tab to the course.
"""
# Add tab to the course if needed
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
# If a tab has been added to the course, then send the
# metadata along to CourseMetadata.update_from_json
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should not be filtered out of
# the metadata
return True
return False
# pylint: disable=invalid-name
def _remove_tab(request, tab_type, course_module):
"""
Removes the tab from the course.
"""
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
return True
return False
def is_advanced_component_present(request, advanced_components):
"""
Return True when one of `advanced_components` is present in the request.
raises TypeError
when request.ADVANCED_COMPONENT_POLICY_KEY is malformed (not iterable)
"""
if ADVANCED_COMPONENT_POLICY_KEY not in request.json:
return False
new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value']
for ac_type in advanced_components:
if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES:
return True
# If we did not find a module type in the advanced settings,
# we may need to remove the tab from the course.
if not found_ac_type: # Remove tab from the course if needed
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should *not* be filtered out of
# the metadata
filter_tabs = False
return filter_tabs
def is_field_value_true(request, field_list):
"""
Return True when one of field values is set to True by request
"""
return any([request.json.get(field, {}).get('value') for field in field_list])
# pylint: disable=invalid-name
def _modify_tabs_to_components(request, course_module):
"""
Automatically adds/removes tabs if user indicated that they want
respective modules enabled in the course
Return True when tab configuration has been modified.
"""
tab_component_map = {
# 'tab_type': (check_function, list_of_checked_components_or_values),
# open ended tab by combinedopendended or peergrading module
'open_ended': (is_advanced_component_present, OPEN_ENDED_COMPONENT_TYPES),
# notes tab
'notes': (is_advanced_component_present, NOTE_COMPONENT_TYPES),
# student notes tab
'edxnotes': (is_field_value_true, ['edxnotes'])
}
tabs_changed = False
for tab_type in tab_component_map.keys():
check, component_types = tab_component_map[tab_type]
try:
tab_enabled = check(request, component_types)
except TypeError:
# user has failed to put iterable value into advanced component list.
# return immediately and let validation handle.
return
if tab_enabled:
# check passed, some of this component_types are present, adding tab
if _add_tab(request, tab_type, course_module):
# tab indeed was added, the change needs to propagate
tabs_changed = True
else:
# the tab should not be present (anymore)
if _remove_tab(request, tab_type, course_module):
# tab indeed was removed, the change needs to propagate
tabs_changed = True
return tabs_changed
@login_required
......@@ -954,8 +992,8 @@ def advanced_settings_handler(request, course_key_string):
return JsonResponse(CourseMetadata.fetch(course_module))
else:
try:
# Whether or not to filter the tabs key out of the settings metadata
filter_tabs = _config_course_advanced_components(request, course_module)
# do not process tabs unless they were modified according to course metadata
filter_tabs = not _modify_tabs_to_components(request, course_module)
# validate data formats and update
is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json(
......
......@@ -47,6 +47,10 @@ class CourseMetadata(object):
if not settings.FEATURES.get('ENABLE_EXPORT_GIT'):
filtered_list.append('giturl')
# Do not show edxnotes if the feature is disabled.
if not settings.FEATURES.get('ENABLE_EDXNOTES'):
filtered_list.append('edxnotes')
return filtered_list
@classmethod
......
......@@ -116,6 +116,11 @@ FEATURES = {
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
'IS_EDX_DOMAIN': False,
# let students save and manage their annotations
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
'ENABLE_EDXNOTES': False,
}
ENABLE_JASMINE = False
......
......@@ -229,3 +229,5 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
FEATURES['ENABLE_EDXNOTES'] = True
define(['js/base', 'coffee/src/main', 'coffee/src/logger', 'datepair', 'accessibility',
define(['js/base', 'coffee/src/main', 'js/src/logger', 'datepair', 'accessibility',
'ieshim', 'tooltip_manager']);
......@@ -220,7 +220,7 @@ require.config({
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/logger": {
"js/src/logger": {
exports: "Logger",
deps: ["coffee/src/ajax_prefix"]
},
......
......@@ -189,7 +189,9 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
)
if headers is None:
headers = dict()
headers = {
'Access-Control-Allow-Origin': "*",
}
BaseHTTPRequestHandler.send_response(self, status_code)
......
......@@ -10,6 +10,7 @@ from .youtube import StubYouTubeService
from .ora import StubOraService
from .lti import StubLtiService
from .video_source import VideoSourceHttpService
from .edxnotes import StubEdxNotesService
USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]"
......@@ -21,6 +22,7 @@ SERVICES = {
'comments': StubCommentsService,
'lti': StubLtiService,
'video': VideoSourceHttpService,
'edxnotes': StubEdxNotesService,
}
# Log to stdout, including debug messages
......
"""
Unit tests for stub EdxNotes implementation.
"""
import json
import unittest
import requests
from uuid import uuid4
from ..edxnotes import StubEdxNotesService
class StubEdxNotesServiceTest(unittest.TestCase):
"""
Test cases for the stub EdxNotes service.
"""
def setUp(self):
"""
Start the stub server.
"""
self.server = StubEdxNotesService()
dummy_notes = self._get_dummy_notes(count=2)
self.server.add_notes(dummy_notes)
self.addCleanup(self.server.shutdown)
def _get_dummy_notes(self, count=1):
"""
Returns a list of dummy notes.
"""
return [self._get_dummy_note() for i in xrange(count)] # pylint: disable=unused-variable
def _get_dummy_note(self):
"""
Returns a single dummy note.
"""
nid = uuid4().hex
return {
"id": nid,
"created": "2014-10-31T10:05:00.000000",
"updated": "2014-10-31T10:50:00.101010",
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
"text": "dummy note text " + nid,
"quote": "dummy note quote",
"ranges": [
{
"start": "/p[1]",
"end": "/p[1]",
"startOffset": 0,
"endOffset": 10,
}
],
}
def test_note_create(self):
dummy_note = {
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
"text": "dummy note text",
"quote": "dummy note quote",
"ranges": [
{
"start": "/p[1]",
"end": "/p[1]",
"startOffset": 0,
"endOffset": 10,
}
],
}
response = requests.post(self._get_url("api/v1/annotations"), data=json.dumps(dummy_note))
self.assertTrue(response.ok)
response_content = response.json()
self.assertIn("id", response_content)
self.assertIn("created", response_content)
self.assertIn("updated", response_content)
self.assertIn("annotator_schema_version", response_content)
self.assertDictContainsSubset(dummy_note, response_content)
def test_note_read(self):
notes = self._get_notes()
for note in notes:
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertDictEqual(note, response.json())
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_note_update(self):
notes = self._get_notes()
for note in notes:
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertDictEqual(note, response.json())
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_search(self):
response = requests.get(self._get_url("api/v1/search"), params={
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
})
notes = self._get_notes()
self.assertTrue(response.ok)
self.assertDictEqual({"total": 2, "rows": notes}, response.json())
response = requests.get(self._get_url("api/v1/search"))
self.assertEqual(response.status_code, 400)
def test_delete(self):
notes = self._get_notes()
response = requests.delete(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
for note in notes:
response = requests.delete(self._get_url("api/v1/annotations/" + note["id"]))
self.assertEqual(response.status_code, 204)
remaining_notes = self.server.get_notes()
self.assertNotIn(note["id"], [note["id"] for note in remaining_notes])
self.assertEqual(len(remaining_notes), 0)
def test_update(self):
note = self._get_notes()[0]
response = requests.put(self._get_url("api/v1/annotations/" + note["id"]), data=json.dumps({
"text": "new test text"
}))
self.assertEqual(response.status_code, 200)
updated_note = self._get_notes()[0]
self.assertEqual("new test text", updated_note["text"])
self.assertEqual(note["id"], updated_note["id"])
self.assertItemsEqual(note, updated_note)
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_notes_collection(self):
response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"})
self.assertTrue(response.ok)
self.assertEqual(len(response.json()), 2)
response = requests.get(self._get_url("api/v1/annotations"))
self.assertEqual(response.status_code, 400)
def test_cleanup(self):
response = requests.put(self._get_url("cleanup"))
self.assertTrue(response.ok)
self.assertEqual(len(self.server.get_notes()), 0)
def test_create_notes(self):
dummy_notes = self._get_dummy_notes(count=2)
response = requests.post(self._get_url("create_notes"), data=json.dumps(dummy_notes))
self.assertTrue(response.ok)
self.assertEqual(len(self._get_notes()), 4)
response = requests.post(self._get_url("create_notes"))
self.assertEqual(response.status_code, 400)
def test_headers(self):
note = self._get_notes()[0]
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
response = requests.options(self._get_url("api/v1/annotations/"))
self.assertTrue(response.ok)
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
self.assertEqual(response.headers.get("access-control-allow-methods"), "GET, POST, PUT, DELETE, OPTIONS")
self.assertIn("X-CSRFToken", response.headers.get("access-control-allow-headers"))
def _get_notes(self):
"""
Return a list of notes from the stub EdxNotes service.
"""
notes = self.server.get_notes()
self.assertGreater(len(notes), 0, "Notes are empty.")
return notes
def _get_url(self, path):
"""
Construt a URL to the stub EdxNotes service.
"""
return "http://127.0.0.1:{port}/{path}/".format(
port=self.server.port, path=path
)
"""
Utilities related to edXNotes.
"""
import sys
def edxnotes(cls):
"""
Conditional decorator that loads edxnotes only when they exist.
"""
if "edxnotes" in sys.modules:
from edxnotes.decorators import edxnotes as notes # pylint: disable=import-error
return notes(cls)
else:
return cls
......@@ -16,6 +16,8 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname
import textwrap
from xmodule.contentstore.content import StaticContent
from xblock.core import XBlock
from xmodule.edxnotes_utils import edxnotes
log = logging.getLogger("edx.courseware")
......@@ -51,7 +53,10 @@ class HtmlFields(object):
)
class HtmlModule(HtmlFields, XModule):
class HtmlModuleMixin(HtmlFields, XModule):
"""
Attributes and methods used by HtmlModules internally.
"""
js = {
'coffee': [
resource_string(__name__, 'js/src/javascript_loader.coffee'),
......@@ -72,6 +77,14 @@ class HtmlModule(HtmlFields, XModule):
return self.data
@edxnotes
class HtmlModule(HtmlModuleMixin):
"""
Module for putting raw html in a course
"""
pass
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
"""
Module for putting raw html in a course
......@@ -255,7 +268,7 @@ class AboutFields(object):
@XBlock.tag("detached")
class AboutModule(AboutFields, HtmlModule):
class AboutModule(AboutFields, HtmlModuleMixin):
"""
Overriding defaults but otherwise treated as HtmlModule.
"""
......@@ -292,7 +305,7 @@ class StaticTabFields(object):
@XBlock.tag("detached")
class StaticTabModule(StaticTabFields, HtmlModule):
class StaticTabModule(StaticTabFields, HtmlModuleMixin):
"""
Supports the field overrides
"""
......@@ -326,7 +339,7 @@ class CourseInfoFields(object):
@XBlock.tag("detached")
class CourseInfoModule(CourseInfoFields, HtmlModule):
class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
"""
Just to support xblock field overrides
"""
......
......@@ -35,7 +35,7 @@ src_paths:
lib_paths:
- common_static/js/test/i18n.js
- common_static/coffee/src/ajax_prefix.js
- common_static/coffee/src/logger.js
- common_static/js/src/logger.js
- common_static/js/vendor/jasmine-jquery.js
- common_static/js/vendor/jasmine-imagediff.js
- common_static/js/vendor/require.js
......
......@@ -34,7 +34,7 @@ describe 'Crowdsourced hinter', ->
response =
success: 'incorrect'
contents: 'mock grader response'
settings.success(response)
settings.success(response) if settings
)
@problem.answers = 'test answer'
@problem.check_fd()
......
......@@ -172,6 +172,19 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings,
default=default_reset_button
)
edxnotes = Boolean(
display_name=_("Enable Student Notes"),
help=_("Enter true or false. If true, students can use the Student Notes feature."),
default=False,
scope=Scope.settings
)
edxnotes_visibility = Boolean(
display_name="Student Notes Visibility",
help=_("Indicates whether Student Notes are visible in the course. "
"Students can also show or hide their notes in the courseware."),
default=True,
scope=Scope.user_info
)
def compute_inherited_metadata(descriptor):
......
......@@ -69,6 +69,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
settings: The configuration settings, including values for:
WIKI_ENABLED
FEATURES['ENABLE_DISCUSSION_SERVICE']
FEATURES['ENABLE_EDXNOTES']
FEATURES['ENABLE_STUDENT_NOTES']
FEATURES['ENABLE_TEXTBOOK']
......@@ -195,6 +196,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
'staff_grading': StaffGradingTab,
'open_ended': OpenEndedGradingTab,
'notes': NotesTab,
'edxnotes': EdxNotesTab,
'syllabus': SyllabusTab,
'instructor': InstructorTab, # not persisted
}
......@@ -694,6 +696,27 @@ class NotesTab(AuthenticatedCourseTab):
return super(NotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
class EdxNotesTab(AuthenticatedCourseTab):
"""
A tab for the course student notes.
"""
type = 'edxnotes'
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
return settings.FEATURES.get('ENABLE_EDXNOTES')
def __init__(self, tab_dict=None):
super(EdxNotesTab, self).__init__(
name=tab_dict['name'] if tab_dict else _('Notes'),
tab_id=self.type,
link_func=link_reverse_func(self.type),
)
@classmethod
def validate(cls, tab_dict, raise_error=True):
return super(EdxNotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
class InstructorTab(StaffTab):
"""
A tab for the course instructors.
......@@ -854,13 +877,13 @@ class CourseTabList(List):
# the following tabs should appear only once
for tab_type in [
CoursewareTab.type,
CourseInfoTab.type,
NotesTab.type,
TextbookTabs.type,
PDFTextbookTabs.type,
HtmlTextbookTabs.type,
]:
CoursewareTab.type,
CourseInfoTab.type,
NotesTab.type,
TextbookTabs.type,
PDFTextbookTabs.type,
HtmlTextbookTabs.type,
EdxNotesTab.type]:
cls._validate_num_tabs_of_type(tabs, tab_type, 1)
@staticmethod
......
......@@ -412,6 +412,40 @@ class InstructorTestCase(TabTestCase):
self.check_can_display_results(tab, for_staff_only=True)
class EdxNotesTestCase(TabTestCase):
"""
Test cases for Notes Tab.
"""
def check_edxnotes_tab(self):
"""
Helper function for verifying the edxnotes tab.
"""
return self.check_tab(
tab_class=tabs.EdxNotesTab,
dict_tab={'type': tabs.EdxNotesTab.type, 'name': 'same'},
expected_link=self.reverse('edxnotes', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.EdxNotesTab.type,
invalid_dict_tab=self.fake_dict_tab,
)
def test_edxnotes_tabs_enabled(self):
"""
Tests that edxnotes tab is shown when feature is enabled.
"""
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
tab = self.check_edxnotes_tab()
self.check_can_display_results(tab, for_authenticated_users_only=True)
def test_edxnotes_tabs_disabled(self):
"""
Tests that edxnotes tab is not shown when feature is disabled.
"""
self.settings.FEATURES['ENABLE_EDXNOTES'] = False
tab = self.check_edxnotes_tab()
self.check_can_display_results(tab, expected_value=False)
class KeyCheckerTestCase(unittest.TestCase):
"""Test cases for KeyChecker class"""
......@@ -473,6 +507,7 @@ class TabListTestCase(TabTestCase):
tabs.TextbookTabs.type,
tabs.PDFTextbookTabs.type,
tabs.HtmlTextbookTabs.type,
tabs.EdxNotesTab.type,
]
for unique_tab_type in unique_tab_types:
......@@ -505,6 +540,7 @@ class TabListTestCase(TabTestCase):
{'type': tabs.OpenEndedGradingTab.type},
{'type': tabs.NotesTab.type, 'name': 'fake_name'},
{'type': tabs.SyllabusTab.type},
{'type': tabs.EdxNotesTab.type, 'name': 'fake_name'},
],
# with external discussion
[
......@@ -565,6 +601,7 @@ class CourseTabListTestCase(TabListTestCase):
self.settings.FEATURES['ENABLE_TEXTBOOK'] = True
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
self.settings.FEATURES['ENABLE_STUDENT_NOTES'] = True
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
self.course.hide_progress_tab = False
# create 1 book per textbook type
......
......@@ -546,7 +546,7 @@ browser and pasting the output. When that file changes, this one should be rege
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
<span class="action-label">Edit</span>
<span class="action-icon"><i class="icon fa fa-pencil"></i></span>
<span class="action-icon"><i class="icon fa fa-pencil-square-o"></i></span>
</a>
</li>
</script>
......
describe 'Logger', ->
it 'expose window.log_event', ->
expect(window.log_event).toBe Logger.log
describe 'log', ->
it 'send a request to log event', ->
spyOn jQuery, 'postWithPrefix'
Logger.log 'example', 'data'
expect(jQuery.postWithPrefix).toHaveBeenCalledWith '/event',
event_type: 'example'
event: '"data"'
page: window.location.href
# Broken with commit 9f75e64? Skipping for now.
xdescribe 'bind', ->
beforeEach ->
Logger.bind()
Courseware.prefix = '/6002x'
afterEach ->
window.onunload = null
it 'bind the onunload event', ->
expect(window.onunload).toEqual jasmine.any(Function)
it 'send a request to log event', ->
spyOn($, 'ajax')
window.onunload()
expect($.ajax).toHaveBeenCalledWith
url: "#{Courseware.prefix}/event",
data:
event_type: 'page_close'
event: ''
page: window.location.href
async: false
class @Logger
# listeners[event_type][element] -> list of callbacks
listeners = {}
@log: (event_type, data, element = null) ->
# Check to see if we're listening for the event type.
if event_type of listeners
# Cool. Do the elements also match?
# null element in the listener dictionary means any element will do.
# null element in the @log call means we don't know the element name.
if null of listeners[event_type]
# Make the callbacks.
for callback in listeners[event_type][null]
callback(event_type, data, element)
else if element of listeners[event_type]
for callback in listeners[event_type][element]
callback(event_type, data, element)
# Regardless of whether any callbacks were made, log this event.
$.postWithPrefix '/event',
event_type: event_type
event: JSON.stringify(data)
page: window.location.href
@listen: (event_type, element, callback) ->
# Add a listener. If you want any element to trigger this listener,
# do element = null
if event_type not of listeners
listeners[event_type] = {}
if element not of listeners[event_type]
listeners[event_type][element] = [callback]
else
listeners[event_type][element].push callback
@bind: ->
window.onunload = ->
$.ajaxWithPrefix
url: "/event"
data:
event_type: 'page_close'
event: ''
page: window.location.href
async: false
# log_event exists for compatibility reasons
# and will soon be deprecated.
@log_event = Logger.log
(function() {
'use strict';
describe('Logger', function() {
it('expose window.log_event', function() {
expect(window.log_event).toBe(Logger.log);
});
describe('log', function() {
it('can send a request to log event', function() {
spyOn(jQuery, 'ajaxWithPrefix');
Logger.log('example', 'data');
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/event',
type: 'POST',
data: {
event_type: 'example',
event: '"data"',
page: window.location.href
},
async: true
});
});
it('can send a request with custom options to log event', function() {
spyOn(jQuery, 'ajaxWithPrefix');
Logger.log('example', 'data', null, {type: 'GET', async: false});
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/event',
type: 'GET',
data: {
event_type: 'example',
event: '"data"',
page: window.location.href
},
async: false
});
});
});
describe('listen', function() {
beforeEach(function () {
spyOn(jQuery, 'ajaxWithPrefix');
this.callbacks = _.map(_.range(4), function () {
return jasmine.createSpy();
});
Logger.listen('example', null, this.callbacks[0]);
Logger.listen('example', null, this.callbacks[1]);
Logger.listen('example', 'element', this.callbacks[2]);
Logger.listen('new_event', null, this.callbacks[3]);
});
it('can listen events when the element name is unknown', function() {
Logger.log('example', 'data');
expect(this.callbacks[0]).toHaveBeenCalledWith('example', 'data', null);
expect(this.callbacks[1]).toHaveBeenCalledWith('example', 'data', null);
expect(this.callbacks[2]).not.toHaveBeenCalled();
expect(this.callbacks[3]).not.toHaveBeenCalled();
});
it('can listen events when the element name is known', function() {
Logger.log('example', 'data', 'element');
expect(this.callbacks[0]).not.toHaveBeenCalled();
expect(this.callbacks[1]).not.toHaveBeenCalled();
expect(this.callbacks[2]).toHaveBeenCalledWith('example', 'data', 'element');
expect(this.callbacks[3]).not.toHaveBeenCalled();
});
});
describe('bind', function() {
beforeEach(function() {
this.initialPostWithPrefix = jQuery.postWithPrefix;
this.initialGetWithPrefix = jQuery.getWithPrefix;
this.initialAjaxWithPrefix = jQuery.ajaxWithPrefix;
this.prefix = '/6002x';
AjaxPrefix.addAjaxPrefix($, _.bind(function () {
return this.prefix;
}, this));
Logger.bind();
});
afterEach(function() {
jQuery.postWithPrefix = this.initialPostWithPrefix;
jQuery.getWithPrefix = this.initialGetWithPrefix;
jQuery.ajaxWithPrefix = this.initialAjaxWithPrefix;
window.onunload = null;
});
it('can bind the onunload event', function() {
expect(window.onunload).toEqual(jasmine.any(Function));
});
it('can send a request to log event', function() {
spyOn(jQuery, 'ajax');
window.onunload();
expect(jQuery.ajax).toHaveBeenCalledWith({
url: this.prefix + '/event',
type: 'GET',
data: {
event_type: 'page_close',
event: '',
page: window.location.href
},
async: false
});
});
});
});
}).call(this);
;(function() {
'use strict';
var Logger = (function() {
// listeners[event_type][element] -> list of callbacks
var listeners = {},
sendRequest, has;
sendRequest = function(data, options) {
var request = $.ajaxWithPrefix ? $.ajaxWithPrefix : $.ajax;
options = $.extend(true, {
'url': '/event',
'type': 'POST',
'data': data,
'async': true
}, options);
return request(options);
};
has = function(object, propertyName) {
return {}.hasOwnProperty.call(object, propertyName);
};
return {
/**
* Emits an event.
*/
log: function(eventType, data, element, requestOptions) {
var callbacks;
if (!element) {
// null element in the listener dictionary means any element will do.
// null element in the Logger.log call means we don't know the element name.
element = null;
}
// Check to see if we're listening for the event type.
if (has(listeners, eventType)) {
if (has(listeners[eventType], element)) {
// Make the callbacks.
callbacks = listeners[eventType][element];
$.each(callbacks, function(index, callback) {
callback(eventType, data, element);
});
}
}
// Regardless of whether any callbacks were made, log this event.
return sendRequest({
'event_type': eventType,
'event': JSON.stringify(data),
'page': window.location.href
}, requestOptions);
},
/**
* Adds a listener. If you want any element to trigger this listener,
* do element = null
*/
listen: function(eventType, element, callback) {
listeners[eventType] = listeners[eventType] || {};
listeners[eventType][element] = listeners[eventType][element] || [];
listeners[eventType][element].push(callback);
},
/**
* Binds `page_close` event.
*/
bind: function() {
window.onunload = function() {
sendRequest({
event_type: 'page_close',
event: '',
page: window.location.href
}, {type: 'GET', async: false});
};
}
};
}());
this.Logger = Logger;
// log_event exists for compatibility reasons and will soon be deprecated.
this.log_event = Logger.log;
}).call(this);
<%! import json %>
<%! from student.models import anonymous_id_for_user %>
<%
if user:
params.update({'user': anonymous_id_for_user(user, None)})
%>
<div id="edx-notes-wrapper-${uid}" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">${content}</div>
</div>
<script type="text/javascript">
(function (require) {
require(['js/edxnotes/views/visibility_decorator'], function(EdxnotesVisibilityDecorator) {
var element = document.getElementById('edx-notes-wrapper-${uid}');
EdxnotesVisibilityDecorator.factory(element, ${json.dumps(params)}, ${edxnotes_visibility});
});
}).call(this, require || RequireJS.require);
</script>
......@@ -14,3 +14,6 @@ ORA_STUB_URL = os.environ.get('ora_url', 'http://localhost:8041')
# Get the URL of the comments service stub used in the test
COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567')
# Get the URL of the EdxNotes service stub used in the test
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042')
"""
Tools for creating edxnotes content fixture data.
"""
import json
import factory
import requests
from . import EDXNOTES_STUB_URL
class Range(factory.Factory):
FACTORY_FOR = dict
start = "/div[1]/p[1]"
end = "/div[1]/p[1]"
startOffset = 0
endOffset = 8
class Note(factory.Factory):
FACTORY_FOR = dict
user = "dummy-user"
usage_id = "dummy-usage-id"
course_id = "dummy-course-id"
text = "dummy note text"
quote = "dummy note quote"
ranges = [Range()]
class EdxNotesFixtureError(Exception):
"""
Error occurred while installing a edxnote fixture.
"""
pass
class EdxNotesFixture(object):
notes = []
def create_notes(self, notes_list):
self.notes = notes_list
return self
def install(self):
"""
Push the data to the stub EdxNotes service.
"""
response = requests.post(
'{}/create_notes'.format(EDXNOTES_STUB_URL),
data=json.dumps(self.notes)
)
if not response.ok:
raise EdxNotesFixtureError(
"Could not create notes {0}. Status was {1}".format(
json.dumps(self.notes), response.status_code
)
)
return self
def cleanup(self):
"""
Cleanup the stub EdxNotes service.
"""
self.notes = []
response = requests.put('{}/cleanup'.format(EDXNOTES_STUB_URL))
if not response.ok:
raise EdxNotesFixtureError(
"Could not cleanup EdxNotes service {0}. Status was {1}".format(
json.dumps(self.notes), response.status_code
)
)
return self
......@@ -61,7 +61,14 @@ class CoursewarePage(CoursePage):
(default is 0)
"""
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
# When Student Notes feature is enabled, it looks for the content inside
# `.edx-notes-wrapper-content` element (Otherwise, you will get an
# additional html related to Student Notes).
element = self.q(css='{} .edx-notes-wrapper-content'.format(self.xblock_component_selector))
if element.first:
return element.attrs('innerHTML')[index].strip()
else:
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
def tooltips_displayed(self):
"""
......
......@@ -9,6 +9,7 @@ import os
from path import path
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import CourseLocator
from bok_choy.javascript import js_defined
def skip_if_browser(browser):
......@@ -90,6 +91,7 @@ def enable_animations(page):
enable_css_animations(page)
@js_defined('window.jQuery')
def disable_jquery_animations(page):
"""
Disable jQuery animations.
......@@ -97,6 +99,7 @@ def disable_jquery_animations(page):
page.browser.execute_script("jQuery.fx.off = true;")
@js_defined('window.jQuery')
def enable_jquery_animations(page):
"""
Enable jQuery animations.
......
[
{
"pk": 1,
"model": "oauth2.client",
"fields": {
"name": "edx-notes",
"url": "http://example.com/",
"client_type": 1,
"redirect_uri": "http://example.com/welcome",
"user": null,
"client_id": "22a9e15e3d3b115e4d43",
"client_secret": "7969f769a1fe21ecd6cf8a1c105f250f70a27131"
}
}
]
"""
Decorators related to edXNotes.
"""
from django.conf import settings
import json
from edxnotes.helpers import (
get_endpoint,
get_id_token,
get_token_url,
generate_uid,
is_feature_enabled,
)
from edxmako.shortcuts import render_to_string
def edxnotes(cls):
"""
Decorator that makes components annotatable.
"""
original_get_html = cls.get_html
def get_html(self, *args, **kwargs):
"""
Returns raw html for the component.
"""
is_studio = getattr(self.system, "is_author_mode", False)
course = self.descriptor.runtime.modulestore.get_course(self.runtime.course_id)
# Must be disabled:
# - in Studio;
# - when Harvard Annotation Tool is enabled for the course;
# - when the feature flag or `edxnotes` setting of the course is set to False.
if is_studio or not is_feature_enabled(course):
return original_get_html(self, *args, **kwargs)
else:
return render_to_string("edxnotes_wrapper.html", {
"content": original_get_html(self, *args, **kwargs),
"uid": generate_uid(),
"edxnotes_visibility": json.dumps(
getattr(self, 'edxnotes_visibility', course.edxnotes_visibility)
),
"params": {
# Use camelCase to name keys.
"usageId": unicode(self.scope_ids.usage_id).encode("utf-8"),
"courseId": unicode(self.runtime.course_id).encode("utf-8"),
"token": get_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)),
"tokenUrl": get_token_url(self.runtime.course_id),
"endpoint": get_endpoint(),
"debug": settings.DEBUG,
},
})
cls.get_html = get_html
return cls
"""
Exceptions related to EdxNotes.
"""
class EdxNotesParseError(Exception):
"""
An exception that is raised whenever we have issues with data parsing.
"""
pass
class EdxNotesServiceUnavailable(Exception):
"""
An exception that is raised whenever EdxNotes service is unavailable.
"""
pass
"""
URLs for EdxNotes.
"""
from django.conf.urls import patterns, url
# Additionally, we include login URLs for the browseable API.
urlpatterns = patterns(
"edxnotes.views",
url(r"^/$", "edxnotes", name="edxnotes"),
url(r"^/search/$", "search_notes", name="search_notes"),
url(r"^/token/$", "get_token", name="get_token"),
url(r"^/visibility/$", "edxnotes_visibility", name="edxnotes_visibility"),
)
"""
Views related to EdxNotes.
"""
import json
import logging
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.conf import settings
from django.core.urlresolvers import reverse
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
from util.json_request import JsonResponse, JsonResponseBadRequest
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
from edxnotes.helpers import (
get_notes,
get_id_token,
is_feature_enabled,
search,
get_course_position,
)
log = logging.getLogger(__name__)
@login_required
def edxnotes(request, course_id):
"""
Displays the EdxNotes page.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
if not is_feature_enabled(course):
raise Http404
try:
notes = get_notes(request.user, course)
except EdxNotesServiceUnavailable:
raise Http404
context = {
"course": course,
"search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}),
"notes": notes,
"debug": json.dumps(settings.DEBUG),
'position': None,
}
if not notes:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id, request.user, course, depth=2
)
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
position = get_course_position(course_module)
if position:
context.update({
'position': position,
})
return render_to_response("edxnotes/edxnotes.html", context)
@login_required
def search_notes(request, course_id):
"""
Handles search requests.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
if not is_feature_enabled(course):
raise Http404
if "text" not in request.GET:
return HttpResponseBadRequest()
query_string = request.GET["text"]
try:
search_results = search(request.user, course, query_string)
except (EdxNotesParseError, EdxNotesServiceUnavailable) as err:
return JsonResponseBadRequest({"error": err.message}, status=500)
return HttpResponse(search_results)
# pylint: disable=unused-argument
@login_required
def get_token(request, course_id):
"""
Get JWT ID-Token, in case you need new one.
"""
return HttpResponse(get_id_token(request.user), content_type='text/plain')
@login_required
def edxnotes_visibility(request, course_id):
"""
Handle ajax call from "Show notes" checkbox.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
field_data_cache = FieldDataCache([course], course_key, request.user)
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
if not is_feature_enabled(course):
raise Http404
try:
visibility = json.loads(request.body)["visibility"]
course_module.edxnotes_visibility = visibility
course_module.save()
return JsonResponse(status=200)
except (ValueError, KeyError):
log.warning(
"Could not decode request body as JSON and find a boolean visibility field: '%s'", request.body
)
return JsonResponseBadRequest()
......@@ -66,6 +66,9 @@ XQUEUE_INTERFACE['url'] = 'http://localhost:8040'
# Configure the LMS to use our stub ORA implementation
OPEN_ENDED_GRADING_INTERFACE['url'] = 'http://localhost:8041/'
# Configure the LMS to use our stub EdxNotes implementation
EDXNOTES_INTERFACE['url'] = 'http://localhost:8042/api/v1'
# Enable django-pipeline and staticfiles
STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath()
......
......@@ -311,6 +311,9 @@ FEATURES = {
# Show the mobile app links in the footer
'ENABLE_FOOTER_MOBILE_APP_LINKS': False,
# let students save and manage their annotations
'ENABLE_EDXNOTES': False,
}
# Ignore static asset files on import which match this pattern
......@@ -911,6 +914,14 @@ MOCK_PEER_GRADING = False
# Used for testing, debugging staff grading
MOCK_STAFF_GRADING = False
################################# EdxNotes config #########################
# Configure the LMS to use our stub EdxNotes implementation
EDXNOTES_INTERFACE = {
'url': 'http://localhost:8120/api/v1',
}
################################# Jasmine ##################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
......@@ -1174,6 +1185,7 @@ PIPELINE_CSS = {
'js/vendor/CodeMirror/codemirror.css',
'css/vendor/jquery.treeview.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/edxnotes/annotator.min.css',
],
'output_filename': 'css/lms-style-course-vendor.css',
},
......@@ -1229,6 +1241,7 @@ PIPELINE_JS = {
'js/src/accessibility_tools.js',
'js/src/ie_shim.js',
'js/src/string_utils.js',
'js/src/logger.js',
],
'output_filename': 'js/lms-application.js',
},
......@@ -1520,6 +1533,8 @@ INSTALLED_APPS = (
'django_comment_common',
'notes',
'edxnotes',
# Splash screen
'splash',
......@@ -1949,3 +1964,7 @@ COURSE_ABOUT_VISIBILITY_PERMISSION = 'see_exists'
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
# for Student Notes we would like to avoid too frequent token refreshes (default is 30 seconds)
if FEATURES['ENABLE_EDXNOTES']:
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
......@@ -425,3 +425,6 @@ MONGODB_LOG = {
'password': '',
'db': 'xlog',
}
# Enable EdxNotes for tests.
FEATURES['ENABLE_EDXNOTES'] = True
......@@ -72,7 +72,7 @@ class @Calculator
.attr
'title': text
'aria-expanded': isExpanded
.text text
.find('.utility-control-label').text text
$calc.toggleClass 'closed'
......
......@@ -2,7 +2,6 @@ class @Courseware
@prefix: ''
constructor: ->
Courseware.prefix = $("meta[name='path_prefix']").attr('content')
new Navigation
Logger.bind()
@render()
......
AjaxPrefix.addAjaxPrefix(jQuery, -> Courseware.prefix)
AjaxPrefix.addAjaxPrefix(jQuery, -> $("meta[name='path_prefix']").attr('content'))
$ ->
$.ajaxSetup
......
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/note'
], function (Backbone, NoteModel) {
var NotesCollection = Backbone.Collection.extend({
model: NoteModel,
/**
* Returns course structure from the list of notes.
* @return {Object}
*/
getCourseStructure: (function () {
var courseStructure = null;
return function () {
var chapters = {},
sections = {},
units = {};
if (!courseStructure) {
this.each(function (note) {
var chapter = note.get('chapter'),
section = note.get('section'),
unit = note.get('unit');
chapters[chapter.location] = chapter;
sections[section.location] = section;
units[unit.location] = units[unit.location] || [];
units[unit.location].push(note);
});
courseStructure = {
chapters: _.sortBy(_.toArray(chapters), function (c) {return c.index;}),
sections: sections,
units: units
};
}
return courseStructure;
};
}())
});
return NotesCollection;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/tab'
], function (Backbone, TabModel) {
var TabsCollection = Backbone.Collection.extend({
model: TabModel
});
return TabsCollection;
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone', 'underscore.string'], function (Backbone) {
var NoteModel = Backbone.Model.extend({
defaults: {
'id': null,
'created': '',
'updated': '',
'user': '',
'usage_id': '',
'course_id': '',
'text': '',
'quote': '',
'ranges': [],
'unit': {
'display_name': '',
'url': '',
'location': ''
},
'section': {
'display_name': '',
'location': '',
'children': []
},
'chapter': {
'display_name': '',
'location': '',
'index': 0,
'children': []
},
// Flag indicating current state of the note: expanded or collapsed.
'is_expanded': false,
// Flag indicating whether `More` link should be shown.
'show_link': false
},
textSize: 300,
initialize: function () {
if (this.get('quote').length > this.textSize) {
this.set('show_link', true);
}
},
getNoteText: function () {
var message = this.get('quote');
if (!this.get('is_expanded') && this.get('show_link')) {
message = _.str.prune(message, this.textSize);
}
return message;
}
});
return NoteModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['backbone'], function (Backbone) {
var TabModel = Backbone.Model.extend({
defaults: {
'identifier': '',
'name': '',
'icon': '',
'is_active': false,
'is_closable': false
},
activate: function () {
this.collection.each(_.bind(function(model) {
// Inactivate all other models.
if (model !== this) {
model.inactivate();
}
}, this));
this.set('is_active', true);
},
inactivate: function () {
this.set('is_active', false);
},
isActive: function () {
return this.get('is_active');
}
});
return TabModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'annotator', 'underscore.string'
], function (_, Annotator) {
/**
* Modifies Annotator.Plugin.Store.annotationCreated to make it trigger a new
* event `annotationFullyCreated` when annotation is fully created and has
* an id.
*/
Annotator.Plugin.Store.prototype.annotationCreated = _.compose(
function (jqXhr) {
return jqXhr.done(_.bind(function (annotation) {
if (annotation && annotation.id){
this.publish('annotationFullyCreated', annotation);
}
}, this));
},
Annotator.Plugin.Store.prototype.annotationCreated
);
/**
* Adds the Events Plugin which emits events to capture user intent.
* Emits the following events:
* - 'edx.course.student_notes.viewed'
* [(user, note ID, datetime), (user, note ID, datetime)] - a list of notes.
* - 'edx.course.student_notes.added'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.edited'
* (user, note ID, old note text, new note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.deleted'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
**/
Annotator.Plugin.Events = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
_.extend(Annotator.Plugin.Events.prototype, new Annotator.Plugin(), {
pluginInit: function () {
_.bindAll(this,
'annotationViewerShown', 'annotationFullyCreated', 'annotationEditorShown',
'annotationEditorHidden', 'annotationUpdated', 'annotationDeleted'
);
this.annotator
.subscribe('annotationViewerShown', this.annotationViewerShown)
.subscribe('annotationFullyCreated', this.annotationFullyCreated)
.subscribe('annotationEditorShown', this.annotationEditorShown)
.subscribe('annotationEditorHidden', this.annotationEditorHidden)
.subscribe('annotationUpdated', this.annotationUpdated)
.subscribe('annotationDeleted', this.annotationDeleted);
},
destroy: function () {
this.annotator
.unsubscribe('annotationViewerShown', this.annotationViewerShown)
.unsubscribe('annotationFullyCreated', this.annotationFullyCreated)
.unsubscribe('annotationEditorShown', this.annotationEditorShown)
.unsubscribe('annotationEditorHidden', this.annotationEditorHidden)
.unsubscribe('annotationUpdated', this.annotationUpdated)
.unsubscribe('annotationDeleted', this.annotationDeleted);
},
annotationViewerShown: function (viewer, annotations) {
// Emits an event only when the annotation already exists on the
// server. Otherwise, `annotation.id` is `undefined`.
var data;
annotations = _.reject(annotations, this.isNew);
data = {
'notes': _.map(annotations, function (annotation) {
return {'note_id': annotation.id};
})
};
if (data.notes.length) {
this.log('edx.course.student_notes.viewed', data);
}
},
annotationFullyCreated: function (annotation) {
var data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.added', data);
},
annotationEditorShown: function (editor, annotation) {
this.oldNoteText = annotation.text || '';
},
annotationEditorHidden: function () {
this.oldNoteText = null;
},
annotationUpdated: function (annotation) {
var data;
if (!this.isNew(annotation)) {
data = _.extend(
this.getDefaultData(annotation),
this.getText('old_note_text', this.oldNoteText)
);
this.log('edx.course.student_notes.edited', data);
}
},
annotationDeleted: function (annotation) {
var data;
// Emits an event only when the annotation already exists on the
// server.
if (!this.isNew(annotation)) {
data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.deleted', data);
}
},
getDefaultData: function (annotation) {
return _.extend(
{
'note_id': annotation.id,
'component_usage_id': annotation.usage_id
},
this.getText('note_text', annotation.text),
this.getText('highlighted_content', annotation.quote)
);
},
getText: function (fieldName, text) {
var info = {},
truncated = false,
limit = this.options.stringLimit;
if (_.isNumber(limit) && _.isString(text) && text.length > limit) {
text = String(text).slice(0, limit);
truncated = true;
}
info[fieldName] = text;
info[fieldName + '_truncated'] = truncated;
return info;
},
/**
* If the model does not yet have an id, it is considered to be new.
* @return {Boolean}
*/
isNew: function (annotation) {
return !_.has(annotation, 'id');
},
log: function (eventName, data) {
this.annotator.logger.emit(eventName, data);
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
/**
* Adds the Scroller Plugin which scrolls to a note with a certain id and
* opens it.
**/
Annotator.Plugin.Scroller = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
$.extend(Annotator.Plugin.Scroller.prototype, new Annotator.Plugin(), {
getIdFromLocationHash: function() {
return window.location.hash.substr(1);
},
pluginInit: function () {
_.bindAll(this, 'onNotesLoaded');
// If the page URL contains a hash, we could be coming from a click
// on an anchor in the notes page. In that case, the hash is the id
// of the note that has to be scrolled to and opened.
if (this.getIdFromLocationHash()) {
this.annotator.subscribe('annotationsLoaded', this.onNotesLoaded);
}
},
destroy: function () {
this.annotator.unsubscribe('annotationsLoaded', this.onNotesLoaded);
},
onNotesLoaded: function (notes) {
var hash = this.getIdFromLocationHash();
this.annotator.logger.log('Scroller', {
'notes:': notes,
'hash': hash
});
_.each(notes, function (note) {
var highlight, offset;
if (note.id === hash && note.highlights.length) {
// Clear the page URL hash, it won't be needed once we've
// scrolled and opened the relevant note. And it would
// unnecessarily repeat the steps below if we come from
// another sequential.
window.location.hash = '';
highlight = $(note.highlights[0]);
offset = highlight.position();
// Open the note
this.annotator.showFrozenViewer([note], {
top: offset.top + 0.5 * highlight.height(),
left: offset.left + 0.5 * highlight.width()
});
// Scroll to highlight
this.scrollIntoView(highlight);
}
}, this);
},
scrollIntoView: function (highlight) {
highlight.focus();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['underscore', 'logger'], function (_, Logger) {
var loggers = [],
NotesLogger, now, destroyLogger;
now = function () {
if (performance && performance.now) {
return performance.now();
} else if (Date.now) {
return Date.now();
} else {
return (new Date()).getTime();
}
};
/**
* Removes a reference on the logger from `loggers`.
* @param {Object} logger An instance of Logger.
*/
destroyLogger = function (logger) {
var index = loggers.length,
removedLogger;
while(index--) {
if (loggers[index].id === logger.id) {
removedLogger = loggers.splice(index, 1)[0];
removedLogger.historyStorage = [];
removedLogger.timeStorage = {};
break;
}
}
};
/**
* NotesLogger constructor.
* @constructor
* @param {String} id Id of the logger.
* @param {Boolean|Number} mode Outputs messages to the Web Console if true.
*/
NotesLogger = function (id, mode) {
this.id = id;
this.historyStorage = [];
this.timeStorage = {};
// 0 - silent;
// 1 - show logs;
this.logLevel = mode;
};
/**
* Outputs a message with appropriate type to the Web Console and
* store it in the history.
* @param {String} logType The type of the log message.
* @param {Arguments} args Information that will be stored.
*/
NotesLogger.prototype._log = function (logType, args) {
if (!this.logLevel) {
return false;
}
this.updateHistory.apply(this, arguments);
// Adds ID at the first place
Array.prototype.unshift.call(args, this.id);
if (console && console[logType]) {
if (console[logType].apply){
console[logType].apply(console, args);
} else { // Do this for IE
console[logType](args.join(' '));
}
}
};
/**
* Outputs a message to the Web Console and store it in the history.
*/
NotesLogger.prototype.log = function () {
this._log('log', arguments);
};
/**
* Outputs an error message to the Web Console and store it in the history.
*/
NotesLogger.prototype.error = function () {
this._log('error', arguments);
};
/**
* Adds information to the history.
*/
NotesLogger.prototype.updateHistory = function () {
this.historyStorage.push(arguments);
};
/**
* Returns the history for the logger.
* @return {Array}
*/
NotesLogger.prototype.getHistory = function () {
return this.historyStorage;
};
/**
* Starts a timer you can use to track how long an operation takes.
* @param {String} label Timer name.
*/
NotesLogger.prototype.time = function (label) {
this.timeStorage[label] = now();
};
/**
* Stops a timer that was previously started by calling NotesLogger.prototype.time().
* @param {String} label Timer name.
*/
NotesLogger.prototype.timeEnd = function (label) {
if (!this.timeStorage[label]) {
return null;
}
this._log('log', [label, now() - this.timeStorage[label], 'ms']);
delete this.timeStorage[label];
};
NotesLogger.prototype.destroy = function () {
destroyLogger(this);
};
/**
* Emits the event.
* @param {String} eventName The name of the event.
* @param {*} data Information about the event.
* @param {Number} timeout Optional timeout for the ajax request in ms.
*/
NotesLogger.prototype.emit = function (eventName, data, timeout) {
var args = [eventName, data];
this.log(eventName, data);
if (timeout) {
args.push(null, {'timeout': timeout});
}
return Logger.log.apply(Logger, args);
};
return {
getLogger: function (id, mode) {
var logger = new NotesLogger(id, mode);
loggers.push(logger);
return logger;
},
destroyLogger: destroyLogger
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore'], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @return The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = '#' + name + '-tpl',
templateText = $(templateSelector).text();
if (!templateText) {
console.error('Failed to load ' + name + ' template');
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var NoteSectionView, NoteGroupView;
NoteSectionView = Backbone.View.extend({
tagName: 'section',
className: 'note-section',
id: function () {
return 'note-section-' + _.uniqueId();
},
template: _.template('<h4 class="course-subtitle"><%- sectionName %></h4>'),
render: function () {
this.$el.prepend(this.template({
sectionName: this.options.section.display_name
}));
return this;
},
addChild: function (child) {
this.$el.append(child);
}
});
NoteGroupView = Backbone.View.extend({
tagName: 'section',
className: 'note-group',
id: function () {
return 'note-group-' + _.uniqueId();
},
template: _.template('<h3 class="course-title"><%- chapterName %></h3>'),
initialize: function () {
this.children = [];
},
render: function () {
var container = document.createDocumentFragment();
this.$el.html(this.template({
chapterName: this.options.chapter.display_name || ''
}));
_.each(this.children, function (section) {
container.appendChild(section.render().el);
});
this.$el.append(container);
return this;
},
addChild: function (sectionInfo) {
var section = new NoteSectionView({section: sectionInfo});
this.children.push(section);
return section;
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteGroupView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore','backbone', 'js/edxnotes/utils/template',
'js/edxnotes/utils/logger'
], function ($, _, Backbone, templateUtils, NotesLogger) {
var NoteItemView = Backbone.View.extend({
tagName: 'article',
className: 'note',
id: function () {
return 'note-' + _.uniqueId();
},
events: {
'click .note-excerpt-more-link': 'moreHandler',
'click .reference-unit-link': 'unitLinkHandler',
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('note-item');
this.logger = NotesLogger.getLogger('note_item', options.debug);
this.listenTo(this.model, 'change:is_expanded', this.render);
},
render: function () {
var context = this.getContext();
this.$el.html(this.template(context));
return this;
},
getContext: function () {
return $.extend({
message: this.model.getNoteText()
}, this.model.toJSON());
},
toggleNote: function () {
var value = !this.model.get('is_expanded');
this.model.set('is_expanded', value);
},
moreHandler: function (event) {
event.preventDefault();
this.toggleNote();
},
unitLinkHandler: function (event) {
var REQUEST_TIMEOUT = 2000;
event.preventDefault();
this.logger.emit('edx.student_notes.used_unit_link', {
'note_id': this.model.get('id'),
'component_usage_id': this.model.get('usage_id')
}, REQUEST_TIMEOUT).always(_.bind(function () {
this.redirectTo(event.target.href);
}, this));
},
redirectTo: function (uri) {
window.location = uri;
},
remove: function () {
this.logger.destroy();
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteItemView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger',
'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller',
'js/edxnotes/plugins/events'
], function ($, _, Annotator, NotesLogger) {
var plugins = ['Auth', 'Store', 'Scroller', 'Events'],
getOptions, setupPlugins, updateHeaders, getAnnotator;
/**
* Returns options for the annotator.
* @param {jQuery Element} The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} Options.
**/
getOptions = function (element, params) {
var defaultParams = {
user: params.user,
usage_id: params.usageId,
course_id: params.courseId
},
prefix = params.endpoint.replace(/(.+)\/$/, '$1');
return {
auth: {
token: params.token,
tokenUrl: params.tokenUrl
},
events: {
stringLimit: 300
},
store: {
prefix: prefix,
annotationData: defaultParams,
loadFromSearch: defaultParams,
urls: {
create: '/annotations/',
read: '/annotations/:id/',
update: '/annotations/:id/',
destroy: '/annotations/:id/',
search: '/search/'
}
}
};
};
/**
* Setups plugins for the annotator.
* @param {Object} annotator An instance of the annotator.
* @param {Array} plugins A list of plugins for the annotator.
* @param {Object} options An options for the annotator.
**/
setupPlugins = function (annotator, plugins, options) {
_.each(plugins, function(plugin) {
var settings = options[plugin.toLowerCase()];
annotator.addPlugin(plugin, settings);
}, this);
};
/**
* Factory method that returns Annotator.js instantiates.
* @param {DOM Element} element The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} An instance of Annotator.js.
**/
getAnnotator = function (element, params) {
var el = $(element),
options = getOptions(el, params),
logger = NotesLogger.getLogger(element.id, params.debug),
annotator;
annotator = el.annotator(options).data('annotator');
setupPlugins(annotator, plugins, options);
annotator.logger = logger;
logger.log({'element': element, 'options': options});
return annotator;
};
return {
factory: getAnnotator
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs_list',
'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/course_structure',
'js/edxnotes/views/tabs/search_results'
], function (
Backbone, TabsCollection, TabsListView, RecentActivityView, CourseStructureView,
SearchResultsView
) {
var NotesPageView = Backbone.View.extend({
initialize: function (options) {
this.options = options;
this.tabsCollection = new TabsCollection();
this.recentActivityView = new RecentActivityView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.searchResultsView = new SearchResultsView({
el: this.el,
tabsCollection: this.tabsCollection,
debug: this.options.debug,
createTabOnInitialization: false
});
this.tabsView = new TabsListView({collection: this.tabsCollection});
this.$('.tab-list')
.append(this.tabsView.render().$el)
.removeClass('is-hidden');
}
});
return NotesPageView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'js/edxnotes/collections/notes', 'js/edxnotes/views/notes_page'
], function ($, NotesCollection, NotesPageView) {
/**
* Factory method for the Notes page.
* @param {Object} params Params for the Notes page.
* @param {Array} params.notesList A list of note models.
* @param {Boolean} params.debugMode Enable the flag to see debug information.
* @param {String} params.endpoint The endpoint of the store.
* @return {Object} An instance of NotesPageView.
*/
return function (params) {
var collection = new NotesCollection(params.notesList);
return new NotesPageView({
el: $('.wrapper-student-notes').get(0),
collection: collection,
debug: params.debugMode,
endpoint: params.endpoint
});
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/utils/logger',
'js/edxnotes/collections/notes'
], function ($, _, Backbone, gettext, NotesLogger, NotesCollection) {
var SearchBoxView = Backbone.View.extend({
events: {
'submit': 'submitHandler'
},
errorMessage: gettext('An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.'),
emptyFieldMessage: (function () {
var message = gettext('Please enter a term in the %(anchor_start)s search field%(anchor_end)s.');
return interpolate(message, {
'anchor_start': '<a href="#search-notes-input">',
'anchor_end': '</a>'
}, true);
} ()),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError', 'onComplete');
this.options = _.defaults(options || {}, {
beforeSearchStart: function () {},
search: function () {},
error: function () {},
complete: function () {}
});
this.logger = NotesLogger.getLogger('search_box', this.options.debug);
this.$el.removeClass('is-hidden');
this.isDisabled = false;
this.logger.log('initialized');
},
submitHandler: function (event) {
event.preventDefault();
this.search();
},
/**
* Prepares server response to appropriate structure.
* @param {Object} data The response form the server.
* @return {Array}
*/
prepareData: function (data) {
var collection;
if (!(data && _.has(data, 'total') && _.has(data, 'rows'))) {
this.logger.log('Wrong data', data, this.searchQuery);
return null;
}
collection = new NotesCollection(data.rows);
return [collection, data.total, this.searchQuery];
},
/**
* Returns search text.
* @return {String}
*/
getSearchQuery: function () {
return this.$el.find('#search-notes-input').val();
},
/**
* Starts search if form is not disabled.
* @return {Boolean} Indicates if search is started or not.
*/
search: function () {
if (this.isDisabled) {
return false;
}
this.searchQuery = this.getSearchQuery();
if (!this.validateField(this.searchQuery)) {
return false;
}
this.options.beforeSearchStart(this.searchQuery);
this.disableForm();
this.sendRequest(this.searchQuery)
.done(this.onSuccess)
.fail(this.onError)
.complete(this.onComplete);
return true;
},
validateField: function (searchQuery) {
if (!($.trim(searchQuery))) {
this.options.error(this.emptyFieldMessage, searchQuery);
return false;
}
return true;
},
onSuccess: function (data) {
var args = this.prepareData(data);
if (args) {
this.options.search.apply(this, args);
this.logger.emit('edx.student_notes.searched', {
'number_of_results': args[1],
'search_string': args[2]
});
} else {
this.options.error(this.errorMessage, this.searchQuery);
}
},
onError:function (jXHR) {
var searchQuery = this.getSearchQuery(),
message;
if (jXHR.responseText) {
try {
message = $.parseJSON(jXHR.responseText).error;
} catch (error) { }
}
this.options.error(message || this.errorMessage, searchQuery);
this.logger.log('Response fails', jXHR.responseText);
},
onComplete: function () {
this.enableForm();
this.options.complete(this.searchQuery);
},
enableForm: function () {
this.isDisabled = false;
this.$el.removeClass('is-looking');
this.$('button[type=submit]').removeClass('is-disabled');
},
disableForm: function () {
this.isDisabled = true;
this.$el.addClass('is-looking');
this.$('button[type=submit]').addClass('is-disabled');
},
/**
* Sends a request with appropriate configurations.
* @param {String} text Search query.
* @return {jQuery.Deferred}
*/
sendRequest: function (text) {
var settings = {
url: this.el.action,
type: this.el.method,
dataType: 'json',
data: {text: text}
};
this.logger.log(settings);
return $.ajax(settings);
}
});
return SearchBoxView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
var _t = Annotator._t;
/**
* We currently run JQuery 1.7.2 in Jasmine tests and LMS.
* AnnotatorJS 1.2.9. uses two calls to addBack (in the two functions
* 'isAnnotator' and 'onHighlightMouseover') which was only defined in
* JQuery 1.8.0. In LMS, it works without throwing an error because
* JQuery.UI 1.10.0 adds support to jQuery<1.8 by augmenting '$.fn' with
* that missing function. It is not the case for all Jasmine unit tests,
* so we add it here if necessary.
**/
if (!$.fn.addBack) {
$.fn.addBack = function (selector) {
return this.add(
selector === null ? this.prevObject : this.prevObject.filter(selector)
);
};
}
/**
* The original _setupDynamicStyle uses a very expensive call to
* Util.maxZIndex(...) that sets the z-index of .annotator-adder,
* .annotator-outer, .annotator-notice, .annotator-filter. We set these
* values in annotator.min.css instead and do nothing here.
*/
Annotator.prototype._setupDynamicStyle = function() { };
Annotator.frozenSrc = null;
/**
* Modifies Annotator.Plugin.Auth.haveValidToken to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
return (
this._unsafeToken &&
this._unsafeToken.sub &&
this._unsafeToken.exp &&
this._unsafeToken.iat &&
this.timeToExpiry() > 0
);
};
/**
* Modifies Annotator.Plugin.Auth.timeToExpiry to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
var now = new Date().getTime() / 1000,
expiry = this._unsafeToken.exp,
timeToExpiry = expiry - now;
return (timeToExpiry > 0) ? timeToExpiry : 0;
};
/**
* Modifies Annotator.highlightRange to add a "tabindex=0" attribute
* to the <span class="annotator-hl"> markup that encloses the note.
* These are then focusable via the TAB key.
**/
Annotator.prototype.highlightRange = _.compose(
function (results) {
$('.annotator-hl', this.wrapper).attr('tabindex', 0);
return results;
},
Annotator.prototype.highlightRange
);
/**
* Modifies Annotator.destroy to unbind click.edxnotes:freeze from the
* document and reset isFrozen to default value, false.
**/
Annotator.prototype.destroy = _.compose(
Annotator.prototype.destroy,
function () {
// We are destroying the instance that has the popup visible, revert to default,
// unfreeze all instances and set their isFrozen to false
if (this === Annotator.frozenSrc) {
this.unfreezeAll();
} else {
// Unfreeze only this instance and unbound associated 'click.edxnotes:freeze' handler
$(document).off('click.edxnotes:freeze' + this.uid);
this.isFrozen = false;
}
if (this.logger && this.logger.destroy) {
this.logger.destroy();
}
// Unbind onNoteClick from click
this.viewer.element.off('click', this.onNoteClick);
}
);
/**
* Modifies Annotator.Viewer.html.item template to add an i18n for the
* buttons.
**/
Annotator.Viewer.prototype.html.item = [
'<li class="annotator-annotation annotator-item">',
'<span class="annotator-controls">',
'<a href="#" title="', _t('View as webpage'), '" class="annotator-link">',
_t('View as webpage'),
'</a>',
'<button title="', _t('Edit'), '" class="annotator-edit">',
_t('Edit'),
'</button>',
'<button title="', _t('Delete'), '" class="annotator-delete">',
_t('Delete'),
'</button>',
'</span>',
'</li>'
].join('');
/**
* Modifies Annotator._setupViewer to add a "click" event on viewer.
**/
Annotator.prototype._setupViewer = _.compose(
function () {
this.viewer.element.on('click', _.bind(this.onNoteClick, this));
return this;
},
Annotator.prototype._setupViewer
);
$.extend(true, Annotator.prototype, {
events: {
'.annotator-hl click': 'onHighlightClick',
'.annotator-viewer click': 'onNoteClick'
},
isFrozen: false,
uid: _.uniqueId(),
onHighlightClick: function (event) {
Annotator.Util.preventEventDefault(event);
if (!this.isFrozen) {
event.stopPropagation();
this.onHighlightMouseover.call(this, event);
}
Annotator.frozenSrc = this;
this.freezeAll();
},
onNoteClick: function (event) {
event.stopPropagation();
Annotator.Util.preventEventDefault(event);
if (!$(event.target).is('.annotator-delete')) {
Annotator.frozenSrc = this;
this.freezeAll();
}
},
freeze: function () {
if (!this.isFrozen) {
// Remove default events
this.removeEvents();
this.viewer.element.unbind('mouseover mouseout');
this.uid = _.uniqueId();
$(document).on('click.edxnotes:freeze' + this.uid, _.bind(this.unfreeze, this));
this.isFrozen = true;
}
},
unfreeze: function () {
if (this.isFrozen) {
// Add default events
this.addEvents();
this.viewer.element.bind({
'mouseover': this.clearViewerHideTimer,
'mouseout': this.startViewerHideTimer
});
this.viewer.hide();
$(document).off('click.edxnotes:freeze'+this.uid);
this.isFrozen = false;
Annotator.frozenSrc = null;
}
},
freezeAll: function () {
_.invoke(Annotator._instances, 'freeze');
},
unfreezeAll: function () {
_.invoke(Annotator._instances, 'unfreeze');
},
showFrozenViewer: function (annotations, location) {
this.showViewer(annotations, location);
this.freezeAll();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'],
function (gettext, _, Backbone, templateUtils) {
var TabItemView = Backbone.View.extend({
tagName: 'li',
className: 'tab',
activeClassName: 'is-active',
events: {
'click': 'selectHandler',
'click a': function (event) { event.preventDefault(); },
'click .action-close': 'closeHandler'
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('tab-item');
this.$el.attr('id', this.model.get('identifier'));
this.listenTo(this.model, {
'change:is_active': function (model, value) {
this.$el.toggleClass(this.activeClassName, value);
if (value) {
this.$('.tab-label').prepend($('<span />', {
'class': 'tab-aria-label sr',
'text': gettext('Current tab')
}));
} else {
this.$('.tab-aria-label').remove();
}
},
'destroy': this.remove
});
},
render: function () {
var html = this.template(this.model.toJSON());
this.$el.html(html);
return this;
},
selectHandler: function (event) {
event.preventDefault();
if (!this.model.isActive()) {
this.model.activate();
}
},
closeHandler: function (event) {
event.preventDefault();
event.stopPropagation();
this.model.destroy();
}
});
return TabItemView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item'],
function (gettext, _, Backbone, NoteItemView) {
var TabPanelView = Backbone.View.extend({
tagName: 'section',
className: 'tab-panel',
title: '',
titleTemplate: _.template('<h2 class="sr"><%- text %></h2>'),
attributes: {
'tabindex': -1
},
initialize: function () {
this.children = [];
},
render: function () {
this.$el.html(this.getTitle());
this.renderContent();
return this;
},
renderContent: function () {
return this;
},
getNotes: function (collection) {
var container = document.createDocumentFragment(),
notes = _.map(collection, function (model) {
var note = new NoteItemView({model: model});
container.appendChild(note.render().el);
return note;
});
this.children = this.children.concat(notes);
return container;
},
getTitle: function () {
return this.title ? this.titleTemplate({text: gettext(this.title)}) : '';
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return TabPanelView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/models/tab'
], function (_, Backbone, TabModel) {
var TabView = Backbone.View.extend({
PanelConstructor: null,
tabInfo: {
name: '',
class_name: ''
},
initialize: function (options) {
_.bindAll(this, 'showLoadingIndicator', 'hideLoadingIndicator');
this.options = _.defaults(options || {}, {
createTabOnInitialization: true
});
if (this.options.createTabOnInitialization) {
this.createTab();
}
},
/**
* Creates a tab for the view.
*/
createTab: function () {
this.tabModel = new TabModel(this.tabInfo);
this.options.tabsCollection.add(this.tabModel);
this.listenTo(this.tabModel, {
'change:is_active': function (model, value) {
if (value) {
this.render();
} else {
this.destroySubView();
}
},
'destroy': function () {
this.destroySubView();
this.tabModel = null;
this.onClose();
}
});
},
/**
* Renders content for the view.
*/
render: function () {
this.hideErrorMessage().showLoadingIndicator();
// If the view is already rendered, destroy it.
this.destroySubView();
this.renderContent().always(this.hideLoadingIndicator);
return this;
},
renderContent: function () {
this.contentView = this.getSubView();
this.$('.wrapper-tabs').append(this.contentView.render().$el);
return $.Deferred().resolve().promise();
},
getSubView: function () {
var collection = this.getCollection();
return new this.PanelConstructor({collection: collection});
},
destroySubView: function () {
if (this.contentView) {
this.contentView.remove();
this.contentView = null;
}
},
/**
* Returns collection for the view.
* @return {Backbone.Collection}
*/
getCollection: function () {
return this.collection;
},
/**
* Callback that is called on closing the tab.
*/
onClose: function () { },
/**
* Returns the page's loading indicator.
*/
getLoadingIndicator: function() {
return this.$('.ui-loading');
},
/**
* Shows the page's loading indicator.
*/
showLoadingIndicator: function() {
this.getLoadingIndicator().removeClass('is-hidden');
return this;
},
/**
* Hides the page's loading indicator.
*/
hideLoadingIndicator: function() {
this.getLoadingIndicator().addClass('is-hidden');
return this;
},
/**
* Shows error message.
*/
showErrorMessage: function (message) {
this.$('.wrapper-msg')
.removeClass('is-hidden')
.find('.msg-content .copy').html(message);
return this;
},
/**
* Hides error message.
*/
hideErrorMessage: function () {
this.$('.wrapper-msg')
.addClass('is-hidden')
.find('.msg-content .copy').html('');
return this;
}
});
return TabView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'js/edxnotes/views/tab_view'
], function (gettext, NoteGroupView, TabPanelView, TabView) {
var CourseStructureView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'structure-panel',
title: 'Location in Course',
renderContent: function () {
var courseStructure = this.collection.getCourseStructure(),
container = document.createDocumentFragment();
_.each(courseStructure.chapters, function (chapterInfo) {
var group = this.getGroup(chapterInfo);
_.each(chapterInfo.children, function (location) {
var sectionInfo = courseStructure.sections[location],
section;
if (sectionInfo) {
section = group.addChild(sectionInfo);
_.each(sectionInfo.children, function (location) {
var notes = courseStructure.units[location];
if (notes) {
section.addChild(this.getNotes(notes))
}
}, this);
}
}, this);
container.appendChild(group.render().el);
}, this);
this.$el.append(container);
return this;
},
getGroup: function (chapter, section) {
var group = new NoteGroupView({
chapter: chapter,
section: section
});
this.children.push(group);
return group;
}
}),
tabInfo: {
name: gettext('Location in Course'),
identifier: 'view-course-structure',
icon: 'fa fa-list-ul'
}
});
return CourseStructureView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view'
], function (gettext, TabPanelView, TabView) {
var RecentActivityView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'recent-panel',
title: 'Recent Activity',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ')
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
tabInfo: {
identifier: 'view-recent-activity',
name: gettext('Recent Activity'),
icon: 'fa fa-clock-o'
}
});
return RecentActivityView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view',
'js/edxnotes/views/search_box'
], function (gettext, TabPanelView, TabView, SearchBoxView) {
var SearchResultsView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'search-results-panel',
title: 'Search Results',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
NoResultsViewConstructor: TabPanelView.extend({
id: 'no-results-panel',
title: 'No results found',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
var message = gettext('No results found for "%(query_string)s". Please try searching again.');
this.$el.append($('<p />', {
text: interpolate(message, {
query_string: this.options.searchQuery
}, true)
}));
return this;
}
}),
tabInfo: {
identifier: 'view-search-results',
name: gettext('Search Results'),
icon: 'fa fa-search',
is_closable: true
},
initialize: function (options) {
_.bindAll(this, 'onBeforeSearchStart', 'onSearch', 'onSearchError');
TabView.prototype.initialize.call(this, options);
this.searchResults = null;
this.searchBox = new SearchBoxView({
el: document.getElementById('search-notes-form'),
debug: this.options.debug,
beforeSearchStart: this.onBeforeSearchStart,
search: this.onSearch,
error: this.onSearchError
});
},
renderContent: function () {
this.getLoadingIndicator().focus();
return this.searchPromise.done(_.bind(function () {
this.contentView = this.getSubView();
if (this.contentView) {
this.$('.wrapper-tabs').append(this.contentView.render().$el);
}
}, this));
},
getSubView: function () {
var collection = this.getCollection();
if (collection) {
if (collection.length) {
return new this.PanelConstructor({
collection: collection,
searchQuery: this.searchResults.searchQuery
});
} else {
return new this.NoResultsViewConstructor({
searchQuery: this.searchResults.searchQuery
});
}
}
return null;
},
getCollection: function () {
if (this.searchResults) {
return this.searchResults.collection;
}
return null;
},
onClose: function () {
this.searchResults = null;
},
onBeforeSearchStart: function () {
this.searchDeferred = $.Deferred();
this.searchPromise = this.searchDeferred.promise();
this.hideErrorMessage();
this.searchResults = null;
// If tab doesn't exist, creates it.
if (!this.tabModel) {
this.createTab();
}
// If tab is not already active, makes it active
if (!this.tabModel.isActive()) {
this.tabModel.activate();
} else {
this.render();
}
},
onSearch: function (collection, total, searchQuery) {
this.searchResults = {
collection: collection,
total: total,
searchQuery: searchQuery
};
if (this.searchDeferred) {
this.searchDeferred.resolve();
}
if (this.contentView) {
this.contentView.$el.focus();
}
},
onSearchError: function (errorMessage) {
this.showErrorMessage(errorMessage);
if (this.searchDeferred) {
this.searchDeferred.reject();
}
}
});
return SearchResultsView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/views/tab_item'
], function (_, Backbone, TabItemView) {
var TabsListView = Backbone.View.extend({
tagName: 'ul',
className: 'tabs',
initialize: function (options) {
this.options = options;
this.listenTo(this.collection, {
'add': this.createTab,
'destroy': function (model, collection) {
if (model.isActive() && collection.length) {
collection.at(0).activate();
}
}
});
},
render: function () {
this.collection.each(this.createTab, this);
if (this.collection.length) {
this.collection.at(0).activate();
}
return this;
},
createTab: function (model) {
var tab = new TabItemView({
model: model
});
tab.render().$el.appendTo(this.$el);
return tab;
}
});
return TabsListView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext',
'annotator', 'js/edxnotes/views/visibility_decorator'
], function($, _, Backbone, gettext, Annotator, EdxnotesVisibilityDecorator) {
var ToggleNotesView = Backbone.View.extend({
events: {
'click .action-toggle-notes': 'toggleHandler'
},
errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError');
this.visibility = options.visibility;
this.visibilityUrl = options.visibilityUrl;
this.label = this.$('.utility-control-label');
this.actionLink = this.$('.action-toggle-notes');
this.actionLink.removeClass('is-disabled');
this.actionToggleMessage = this.$('.action-toggle-message');
this.notification = new Annotator.Notification();
},
toggleHandler: function (event) {
event.preventDefault();
this.visibility = !this.visibility;
this.showActionMessage();
this.toggleNotes(this.visibility);
},
toggleNotes: function (visibility) {
if (visibility) {
this.enableNotes();
} else {
this.disableNotes();
}
this.sendRequest();
},
showActionMessage: function () {
// The following lines are necessary to re-trigger the CSS animation on span.action-toggle-message
this.actionToggleMessage.removeClass('is-fleeting');
this.actionToggleMessage.offset().width = this.actionToggleMessage.offset().width;
this.actionToggleMessage.addClass('is-fleeting');
},
enableNotes: function () {
_.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote);
this.actionLink.addClass('is-active').attr('aria-pressed', true);
this.label.text(gettext('Hide notes'));
this.actionToggleMessage.text(gettext('Showing notes'));
},
disableNotes: function () {
EdxnotesVisibilityDecorator.disableNotes();
this.actionLink.removeClass('is-active').attr('aria-pressed', false);
this.label.text(gettext('Show notes'));
this.actionToggleMessage.text(gettext('Hiding notes'));
},
hideErrorMessage: function() {
this.notification.hide();
},
showErrorMessage: function(message) {
this.notification.show(message, Annotator.Notification.ERROR);
},
sendRequest: function () {
return $.ajax({
type: 'PUT',
url: this.visibilityUrl,
dataType: 'json',
data: JSON.stringify({'visibility': this.visibility}),
success: this.onSuccess,
error: this.onError
});
},
onSuccess: function () {
this.hideErrorMessage();
},
onError: function () {
this.showErrorMessage(this.errorMessage);
}
});
return function (visibility, visibilityUrl) {
return new ToggleNotesView({
el: $('.edx-notes-visibility').get(0),
visibility: visibility,
visibilityUrl: visibilityUrl
});
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'js/edxnotes/views/notes_factory'
], function($, _, NotesFactory) {
var parameters = {}, visibility = null,
getIds, createNote, cleanup, factory;
getIds = function () {
return _.map($('.edx-notes-wrapper'), function (element) {
return element.id;
});
};
createNote = function (element, params) {
if (params) {
return NotesFactory.factory(element, params);
}
return null;
};
cleanup = function (ids) {
var list = _.clone(Annotator._instances);
ids = ids || [];
_.each(list, function (instance) {
var id = instance.element.attr('id');
if (!_.contains(ids, id)) {
instance.destroy();
}
});
};
factory = function (element, params, isVisible) {
// When switching sequentials, we need to keep track of the
// parameters of each element and the visibility (that may have been
// changed by the checkbox).
parameters[element.id] = params;
if (_.isNull(visibility)) {
visibility = isVisible;
}
if (visibility) {
// When switching sequentials, the global object Annotator still
// keeps track of the previous instances that were created in an
// array called 'Annotator._instances'. We have to destroy these
// but keep those found on page being loaded (for the case when
// there are more than one HTMLcomponent per vertical).
cleanup(getIds());
return createNote(element, params);
}
return null;
};
return {
factory: factory,
enableNote: function (element) {
createNote(element, parameters[element.id]);
visibility = true;
},
disableNotes: function () {
cleanup();
visibility = false;
},
_setVisibility: function (state) {
visibility = state;
},
}
});
}).call(this, define || RequireJS.define);
<section class="container">
<div class="wrapper-student-notes">
<div class="student-notes">
<div class="title-search-container">
<div class="wrapper-title">
<h1 class="page-title">
Notes
<small class="page-subtitle">Highlights and notes you've made in course content</small>
</h1>
</div>
<div class="wrapper-notes-search">
<form role="search" action="/search_endpoint" method="GET" id="search-notes-form" class="is-hidden">
<label for="search-notes-input" class="sr">Search notes for:</label>
<input type="search" class="search-notes-input" id="search-notes-input" name="note" placeholder="Search notes for...">
<button type="submit" class="search-notes-submit">
<i class="icon fa fa-search"></i>
<span class="sr">Search</span>
</button>
</form>
</div>
</div>
<div class="wrapper-msg is-hidden error urgency-high inline-error">
<div class="msg msg-error">
<div class="msg-content">
<p class="copy" aria-live="polite"></p>
</div>
</div>
</div>
<section class="wrapper-tabs">
<div class="tab-list is-hidden">
<h2 id="tab-view" class="tabs-label">View notes by</h2>
</div>
<div class="ui-loading" tabindex="-1">
<span class="spin">
<i class="icon fa fa-refresh"></i>
</span>
<span class="copy">Loading</span>
</div>
</section>
</div>
</div>
</section>
<div id="edx-notes-wrapper-123" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>
<div id="edx-notes-wrapper-456" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>
<div class="wrapper-utility edx-notes-visibility">
<span class="action-toggle-message">Hiding notes</span>
<button class="utility-control utility-control-button action-toggle-notes is-disabled is-active" aria-pressed="true">
<i class="icon fa fa-pencil"></i>
<span class="utility-control-label sr">Hide notes</span>
</button>
</div>
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NotesCollection', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function () {
this.collection = new NotesCollection(notes);
});
it('can return correct course structure', function () {
var structure = this.collection.getCourseStructure();
expect(structure.chapters).toEqual([
Helpers.getChapter('First Chapter', 1, 0, [2]),
Helpers.getChapter('Second Chapter', 0, 1, [1, 'w_n', 0])
]);
expect(structure.sections).toEqual({
'i4x://section/0': Helpers.getSection('Third Section', 0, ['w_n', 1, 0]),
'i4x://section/1': Helpers.getSection('Second Section', 1, [2]),
'i4x://section/2': Helpers.getSection('First Section', 2, [3])
});
expect(structure.units).toEqual({
'i4x://unit/0': [this.collection.at(0), this.collection.at(1)],
'i4x://unit/1': [this.collection.at(2)],
'i4x://unit/2': [this.collection.at(3)],
'i4x://unit/3': [this.collection.at(4)]
});
});
});
});
define(['jquery'], function($) {
'use strict';
return function (that) {
that.addMatchers({
toContainText: function (text) {
var trimmedText = $.trim($(this.actual).text());
if (text && $.isFunction(text.test)) {
return text.test(trimmedText);
} else {
return trimmedText.indexOf(text) !== -1;
}
},
toHaveLength: function (number) {
return $(this.actual).length === number;
},
toHaveIndex: function (number) {
return $(this.actual).index() === number;
},
toBeInRange: function (min, max) {
return min <= this.actual && this.actual <= max;
},
toBeFocused: function () {
return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement;
}
});
};
});
define(['underscore'], function(_) {
'use strict';
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT,
base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes;
LONG_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Excepteur ',
'sint occaecat cupidatat non proident, sunt in culpa ',
'qui officia deserunt mollit anim id est laborum.'
].join('');
PRUNED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur...'
].join('');
TRUNCATED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Exce'
].join('');
SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt';
base64Encode = function (data) {
var ac, bits, enc, h1, h2, h3, h4, i, o1, o2, o3, r, tmp_arr;
if (btoa) {
// Gecko and Webkit provide native code for this
return btoa(data);
} else {
// Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_encode
// version 1109.2015
i = 0;
ac = 0;
enc = "";
tmp_arr = [];
if (!data) {
return data;
}
data += '';
while (i < data.length) {
o1 = data.charCodeAt(i++);
o2 = data.charCodeAt(i++);
o3 = data.charCodeAt(i++);
bits = o1 << 16 | o2 << 8 | o3;
h1 = bits >> 18 & 0x3f;
h2 = bits >> 12 & 0x3f;
h3 = bits >> 6 & 0x3f;
h4 = bits & 0x3f;
tmp_arr[ac++] = B64.charAt(h1) + B64.charAt(h2) + B64.charAt(h3) + B64.charAt(h4);
}
enc = tmp_arr.join('');
r = data.length % 3;
return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
}
};
makeToken = function() {
var now = (new Date()).getTime() / 1000,
rawToken = {
sub: "sub",
exp: now + 100,
iat: now
};
return 'header.' + base64Encode(JSON.stringify(rawToken)) + '.signature';
};
getChapter = function (name, location, index, children) {
return {
display_name: name,
location: 'i4x://chapter/' + location,
index: index,
children: _.map(children, function (i) {
return 'i4x://section/' + i;
})
};
};
getSection = function (name, location, children) {
return {
display_name: name,
location: 'i4x://section/' + location,
children: _.map(children, function (i) {
return 'i4x://unit/' + i;
})
};
};
getUnit = function (name, location) {
return {
display_name: name,
location: 'i4x://unit/' + location,
url: 'http://example.com'
};
};
getDefaultNotes = function () {
return [
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Note 4'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Third added model',
quote: 'Note 5'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Third Unit', 1),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Note 3'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Second Section', 1, [2]),
unit: getUnit('Second Unit', 2),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 2'
},
{
chapter: getChapter('First Chapter', 1, 0, [2]),
section: getSection('First Section', 2, [3]),
unit: getUnit('First Unit', 3),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 1'
}
];
};
return {
LONG_TEXT: LONG_TEXT,
PRUNED_TEXT: PRUNED_TEXT,
TRUNCATED_TEXT: TRUNCATED_TEXT,
SHORT_TEXT: SHORT_TEXT,
base64Encode: base64Encode,
makeToken: makeToken,
getChapter: getChapter,
getSection: getSection,
getUnit: getUnit,
getDefaultNotes: getDefaultNotes
};
});
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NoteModel', function() {
beforeEach(function () {
this.collection = new NotesCollection([
{quote: Helpers.LONG_TEXT},
{quote: Helpers.SHORT_TEXT}
]);
});
it('has correct values on initialization', function () {
expect(this.collection.at(0).get('is_expanded')).toBeFalsy();
expect(this.collection.at(0).get('show_link')).toBeTruthy();
expect(this.collection.at(1).get('is_expanded')).toBeFalsy();
expect(this.collection.at(1).get('show_link')).toBeFalsy();
});
it('can return appropriate note text', function () {
var model = this.collection.at(0);
// is_expanded = false, show_link = true
expect(model.getNoteText()).toBe(Helpers.PRUNED_TEXT);
model.set('is_expanded', true);
// is_expanded = true, show_link = true
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
model.set('show_link', false);
model.set('is_expanded', false);
// is_expanded = false, show_link = false
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
});
});
});
define([
'js/edxnotes/collections/tabs'
], function(TabsCollection) {
'use strict';
describe('EdxNotes TabModel', function() {
beforeEach(function () {
this.collection = new TabsCollection([{}, {}, {}]);
});
it('when activate current model, all other models are inactivated', function () {
this.collection.at(1).activate();
expect(this.collection.at(1).get('is_active')).toBeTruthy();
expect(this.collection.at(0).get('is_active')).toBeFalsy();
expect(this.collection.at(2).get('is_active')).toBeFalsy();
});
it('can inactivate current model', function () {
var model = this.collection.at(0);
model.activate();
expect(model.get('is_active')).toBeTruthy();
model.inactivate();
expect(model.get('is_active')).toBeFalsy();
});
it('can see correct activity status via isActive', function () {
var model = this.collection.at(0);
model.activate();
expect(model.isActive()).toBeTruthy();
model.inactivate();
expect(model.isActive()).toBeFalsy();
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'annotator', 'logger', 'js/edxnotes/views/notes_factory'
], function($, _, AjaxHelpers, Helpers, Annotator, Logger, NotesFactory) {
'use strict';
describe('EdxNotes Events Plugin', function() {
var note = {
user: 'user-123',
id: 'note-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
},
noteWithoutId = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
beforeEach(function() {
this.annotator = NotesFactory.factory(
$('<div />').get(0), {
endpoint: 'http://example.com/'
}
);
spyOn(Logger, 'log');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should log edx.course.student_notes.viewed event properly', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer,
[note, {user: 'user-456'}, {user: 'user-789', id: 'note-789'}]
]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.viewed', {
'notes': [{'note_id': 'note-123'}, {'note_id': 'note-789'}]
}
);
});
it('should not log edx.course.student_notes.viewed event if all notes are new', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer, [{user: 'user-456'}, {user: 'user-789'}]
]);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should log edx.course.student_notes.added event properly', function() {
var requests = AjaxHelpers.requests(this),
newNote = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
this.annotator.publish('annotationCreated', newNote);
AjaxHelpers.respondWithJson(requests, note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.added', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should log the edx.course.student_notes.edited event properly', function() {
var oldNote = note,
newNote = $.extend({}, note, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': 'text-123',
'old_note_text_truncated': false,
'note_text': 'text-456',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should not log the edx.course.student_notes.edited event if the note is new', function() {
var oldNote = noteWithoutId,
newNote = $.extend({}, noteWithoutId, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).not.toHaveBeenCalled();
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should log the edx.course.student_notes.deleted event properly', function() {
this.annotator.publish('annotationDeleted', note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.deleted', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should not log the edx.course.student_notes.deleted event if the note is new', function() {
this.annotator.publish('annotationDeleted', noteWithoutId);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should truncate values of some fields', function() {
var oldNote = $.extend({}, note, {text: Helpers.LONG_TEXT}),
newNote = $.extend({}, note, {
text: Helpers.LONG_TEXT + '123',
quote: Helpers.LONG_TEXT + '123'
});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe(Helpers.LONG_TEXT);
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': Helpers.TRUNCATED_TEXT,
'old_note_text_truncated': true,
'note_text': Helpers.TRUNCATED_TEXT,
'note_text_truncated': true,
'highlighted_content': Helpers.TRUNCATED_TEXT,
'highlighted_content_truncated': true,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
});
});
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory',
'js/spec/edxnotes/custom_matchers'
], function($, _, Annotator, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes Scroll Plugin', function() {
var annotators, highlights;
function checkAnnotatorIsFrozen(annotator) {
expect(annotator.isFrozen).toBe(true);
expect(annotator.onHighlightMouseover).not.toHaveBeenCalled();
expect(annotator.startViewerHideTimer).not.toHaveBeenCalled();
}
function checkAnnotatorIsUnfrozen(annotator) {
expect(annotator.isFrozen).toBe(false);
expect(annotator.onHighlightMouseover).toHaveBeenCalled();
expect(annotator.startViewerHideTimer).toHaveBeenCalled();
}
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
annotators = [
NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), {
endpoint: 'http://example.com/'
}),
NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), {
endpoint: 'http://example.com/'
})
];
highlights = _.map(annotators, function(annotator) {
spyOn(annotator, 'onHighlightClick').andCallThrough();
spyOn(annotator, 'onHighlightMouseover').andCallThrough();
spyOn(annotator, 'startViewerHideTimer').andCallThrough();
return $('<span></span>', {
'class': 'annotator-hl',
'tabindex': -1,
'text': 'some content'
}).appendTo(annotator.element);
});
spyOn(annotators[0].plugins.Scroller, 'getIdFromLocationHash').andReturn('abc123');
spyOn($.fn, 'unbind').andCallThrough();
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should scroll to a note, open it and freeze the annotator if its id is part of the url hash', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'abc123',
highlights: [highlights[0]]
}]);
annotators[0].onHighlightMouseover.reset();
expect(highlights[0]).toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsFrozen(annotators[0]);
});
it('should not do anything if the url hash contains a wrong id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'def456',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should not do anything if the url hash contains an empty id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: '',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should unbind onNotesLoaded on destruction', function() {
annotators[0].plugins.Scroller.destroy();
expect($.fn.unbind).toHaveBeenCalledWith(
'annotationsLoaded',
annotators[0].plugins.Scroller.onNotesLoaded
);
});
});
});
define([
'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers'
], function(Logger, NotesLogger, customMatchers) {
'use strict';
describe('Edxnotes NotesLogger', function() {
var getLogger = function(id, mode) {
return NotesLogger.getLogger(id, mode);
};
beforeEach(function () {
spyOn(window.console, 'log');
spyOn(window.console, 'error');
spyOn(Logger, 'log');
customMatchers(this);
});
it('keeps a correct history of logs', function() {
var logger = getLogger('id', 1),
logs, log;
logger.log('A log type', 'A first log');
logger.log('A log type', 'A second log');
expect(window.console.log).toHaveBeenCalled();
logs = logger.getHistory();
// Test first log
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A first log');
// Test second log
log = logs[1];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A second log');
});
it('keeps a correct history of errors', function() {
var logger = getLogger('id', 1),
logs, log;
logger.error('An error type', 'A first error');
logger.error('An error type', 'A second error');
expect(window.console.error).toHaveBeenCalled();
logs = logger.getHistory();
// Test first error
log = logs[0];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A first error');
// Test second error
log = logs[1];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A second error');
});
it('can destroy the logger', function() {
var logger = getLogger('id', 1),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(2);
logger.destroy();
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not store the history in silent mode', function() {
var logger = getLogger('id', 0),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not show logs in the console in silent mode', function() {
var logger = getLogger('id', 0);
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
expect(window.console.log).not.toHaveBeenCalled();
expect(window.console.error).not.toHaveBeenCalled();
});
it('can use timers', function() {
var logger = getLogger('id', 1),
now, t0, logs, log;
now = function () {
return (new Date()).getTime();
};
t0 = now();
logger.time('timer');
while (now() - t0 < 200) {}
logger.timeEnd('timer');
logs = logger.getHistory();
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('timer');
expect(log[1][2]).toBeInRange(180, 220);
expect(log[1][3]).toBe('ms');
});
it('can emit an event properly', function () {
var logger = getLogger('id', 0);
logger.emit('event_name', {id: 'some_id'})
expect(Logger.log).toHaveBeenCalledWith('event_name', {
id: 'some_id'
});
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers',
'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'logger',
'js/edxnotes/models/note', 'js/edxnotes/views/note_item',
'js/spec/edxnotes/custom_matchers'
], function(
$, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView,
customMatchers
) {
'use strict';
describe('EdxNotes NoteItemView', function() {
var getView = function (model) {
model = new NoteModel(_.defaults(model || {}, {
id: 'id-123',
user: 'user-123',
usage_id: 'usage_id-123',
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: Helpers.LONG_TEXT,
unit: {
url: 'http://example.com/'
}
}));
return new NoteItemView({model: model}).render();
};
beforeEach(function() {
customMatchers(this);
TemplateHelpers.installTemplate('templates/edxnotes/note-item');
spyOn(Logger, 'log').andCallThrough();
});
it('can be rendered properly', function() {
var view = getView(),
unitLink = view.$('.reference-unit-link').get(0);
expect(view.$el).toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.PRUNED_TEXT);
expect(view.$el).toContainText('More');
view.$('.note-excerpt-more-link').click();
expect(view.$el).toContainText(Helpers.LONG_TEXT);
expect(view.$el).toContainText('Less');
view = getView({quote: Helpers.SHORT_TEXT});
expect(view.$el).not.toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.SHORT_TEXT);
expect(unitLink.hash).toBe('#id-123');
});
it('should display update value and accompanying text', function() {
var view = getView();
expect(view.$('.reference-title').last()).toContainText('Last Edited:');
expect(view.$('.reference-meta').last()).toContainText('December 11, 2014 at 11:12AM');
});
it('should log the edx.student_notes.used_unit_link event properly', function () {
var requests = AjaxHelpers.requests(this),
view = getView();
spyOn(view, 'redirectTo');
view.$('.reference-unit-link').click();
expect(Logger.log).toHaveBeenCalledWith(
'edx.student_notes.used_unit_link',
{
'note_id': 'id-123',
'component_usage_id': 'usage_id-123'
},
null,
{
'timeout': 2000
}
);
expect(view.redirectTo).not.toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(view.redirectTo).toHaveBeenCalledWith('http://example.com/#id-123');
});
});
});
define([
'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers',
'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers'
], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) {
'use strict';
describe('EdxNotes NotesFactory', function() {
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
this.wrapper = document.getElementById('edx-notes-wrapper-123');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('can initialize annotator correctly', function() {
var requests = AjaxHelpers.requests(this),
token = Helpers.makeToken(),
options = {
user: 'a user',
usage_id : 'an usage',
course_id: 'a course'
},
annotator = NotesFactory.factory(this.wrapper, {
endpoint: '/test_endpoint',
user: 'a user',
usageId : 'an usage',
courseId: 'a course',
token: token,
tokenUrl: '/test_token_url'
}),
request = requests[0];
expect(requests).toHaveLength(1);
expect(request.requestHeaders['x-annotator-auth-token']).toBe(token);
expect(annotator.options.auth.tokenUrl).toBe('/test_token_url');
expect(annotator.options.store.prefix).toBe('/test_endpoint');
expect(annotator.options.store.annotationData).toEqual(options);
expect(annotator.options.store.loadFromSearch).toEqual(options);
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'js/edxnotes/views/page_factory', 'js/spec/edxnotes/custom_matchers'
], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes NotesPage', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.view = new NotesFactory({notesList: notes});
});
it('should be displayed properly', function() {
var requests = AjaxHelpers.requests(this),
tab;
expect(this.view.$('#view-search-results')).not.toExist();
tab = this.view.$('#view-recent-activity');
expect(tab).toHaveClass('is-active');
expect(tab.index()).toBe(0);
tab = this.view.$('#view-course-structure');
expect(tab).toExist();
expect(tab.index()).toBe(1);
expect(this.view.$('.tab-panel')).toExist();
this.view.$('.search-notes-input').val('test_query');
this.view.$('.search-notes-submit').click();
AjaxHelpers.respondWithJson(requests, {
total: 0,
rows: []
});
expect(this.view.$('#view-search-results')).toHaveClass('is-active');
expect(this.view.$('#view-recent-activity')).toExist();
expect(this.view.$('#view-course-structure')).toExist();
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/search_box',
'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers) {
'use strict';
describe('EdxNotes SearchBoxView', function() {
var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled;
getSearchBox = function (options) {
options = _.defaults(options || {}, {
el: $('#search-notes-form').get(0),
beforeSearchStart: jasmine.createSpy(),
search: jasmine.createSpy(),
error: jasmine.createSpy(),
complete: jasmine.createSpy()
});
return new SearchBoxView(options);
};
submitForm = function (searchBox, text) {
searchBox.$('.search-notes-input').val(text);
searchBox.$('.search-notes-submit').click();
};
assertBoxIsEnabled = function (searchBox) {
expect(searchBox.$el).not.toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).not.toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeFalsy();
};
assertBoxIsDisabled = function (searchBox) {
expect(searchBox.$el).toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeTruthy();
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
spyOn(Logger, 'log');
this.searchBox = getSearchBox();
});
it('sends a request with proper information on submit the form', function () {
var requests = AjaxHelpers.requests(this),
form = this.searchBox.el,
request;
submitForm(this.searchBox, 'test_text');
request = requests[0];
expect(request.method).toBe(form.method.toUpperCase());
expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'}));
});
it('returns success result', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
expect(this.searchBox.options.beforeSearchStart).toHaveBeenCalledWith(
'test_text'
);
assertBoxIsDisabled(this.searchBox);
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.search).toHaveBeenCalledWith(
jasmine.any(NotesCollection), 2, 'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('should log the edx.student_notes.searched event properly', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
expect(Logger.log).toHaveBeenCalledWith('edx.student_notes.searched', {
'number_of_results': 2,
'search_string': 'test_text'
});
});
it('returns default error message if received data structure is wrong', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {});
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns default error message if network error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithError(requests);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns error message if server error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
requests[0].respond(
500, {'Content-Type': 'application/json'},
JSON.stringify({
error: 'test error message'
})
);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'test error message',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('does not send second request during current search', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
submitForm(this.searchBox, 'another_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(requests).toHaveLength(1);
});
it('returns error message if the field is empty', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, ' ');
expect(requests).toHaveLength(0);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'Please enter a term in the <a href="#search-notes-input"> search field</a>.',
' '
);
});
});
});
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