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>
This commit is contained in:
@@ -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):
|
||||
def _add_tab(request, tab_type, 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
|
||||
Adds tab to the 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:
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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_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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
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"]
|
||||
},
|
||||
|
||||
330
common/djangoapps/terrain/stubs/edxnotes.py
Normal file
330
common/djangoapps/terrain/stubs/edxnotes.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Stub implementation of EdxNotes for acceptance tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from copy import deepcopy
|
||||
|
||||
from .http import StubHttpRequestHandler, StubHttpService
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
class StubEdxNotesServiceHandler(StubHttpRequestHandler):
|
||||
"""
|
||||
Handler for EdxNotes requests.
|
||||
"""
|
||||
URL_HANDLERS = {
|
||||
"GET": {
|
||||
"/api/v1/annotations$": "_collection",
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_read",
|
||||
"/api/v1/search$": "_search",
|
||||
},
|
||||
"POST": {
|
||||
"/api/v1/annotations$": "_create",
|
||||
"/create_notes": "_create_notes",
|
||||
},
|
||||
"PUT": {
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_update",
|
||||
"/cleanup$": "_cleanup",
|
||||
},
|
||||
"DELETE": {
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_delete",
|
||||
},
|
||||
}
|
||||
|
||||
def _match_pattern(self, pattern_handlers):
|
||||
"""
|
||||
Finds handler by the provided handler patterns and delegate response to
|
||||
the matched handler.
|
||||
"""
|
||||
for pattern in pattern_handlers:
|
||||
match = re.match(pattern, self.path_only)
|
||||
if match:
|
||||
handler = getattr(self, pattern_handlers[pattern], None)
|
||||
if handler:
|
||||
handler(**match.groupdict())
|
||||
return True
|
||||
return None
|
||||
|
||||
def _send_handler_response(self, method):
|
||||
"""
|
||||
Delegate response to handler methods.
|
||||
If no handler defined, send a 404 response.
|
||||
"""
|
||||
# Choose the list of handlers based on the HTTP method
|
||||
if method in self.URL_HANDLERS:
|
||||
handlers_list = self.URL_HANDLERS[method]
|
||||
else:
|
||||
self.log_error("Unrecognized method '{method}'".format(method=method))
|
||||
return
|
||||
|
||||
# Check the path (without querystring params) against our list of handlers
|
||||
if self._match_pattern(handlers_list):
|
||||
return
|
||||
# If we don't have a handler for this URL and/or HTTP method,
|
||||
# respond with a 404.
|
||||
else:
|
||||
self.send_response(404, content="404 Not Found")
|
||||
|
||||
def do_GET(self):
|
||||
"""
|
||||
Handle GET methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("GET")
|
||||
|
||||
def do_POST(self):
|
||||
"""
|
||||
Handle POST methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("POST")
|
||||
|
||||
def do_PUT(self):
|
||||
"""
|
||||
Handle PUT methods to the EdxNotes API stub.
|
||||
"""
|
||||
if self.path.startswith("/set_config"):
|
||||
return StubHttpRequestHandler.do_PUT(self)
|
||||
|
||||
self._send_handler_response("PUT")
|
||||
|
||||
def do_DELETE(self):
|
||||
"""
|
||||
Handle DELETE methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("DELETE")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""
|
||||
Handle OPTIONS methods to the EdxNotes API stub.
|
||||
"""
|
||||
self.send_response(200, headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Length, Content-Type, X-Annotator-Auth-Token, X-Requested-With, X-Annotator-Auth-Token, X-Requested-With, X-CSRFToken",
|
||||
})
|
||||
|
||||
def respond(self, status_code=200, content=None):
|
||||
"""
|
||||
Send a response back to the client with the HTTP `status_code` (int),
|
||||
the given content serialized as JSON (str), and the headers set appropriately.
|
||||
"""
|
||||
headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
if status_code < 400 and content:
|
||||
headers["Content-Type"] = "application/json"
|
||||
content = json.dumps(content)
|
||||
else:
|
||||
headers["Content-Type"] = "text/html"
|
||||
|
||||
self.send_response(status_code, content, headers)
|
||||
|
||||
def _create(self):
|
||||
"""
|
||||
Create a note, assign id, annotator_schema_version, created and updated dates.
|
||||
"""
|
||||
note = json.loads(self.request_content)
|
||||
note.update({
|
||||
"id": uuid4().hex,
|
||||
"annotator_schema_version": "v1.0",
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
"updated": datetime.utcnow().isoformat(),
|
||||
})
|
||||
self.server.add_notes(note)
|
||||
self.respond(content=note)
|
||||
|
||||
def _create_notes(self):
|
||||
"""
|
||||
The same as self._create, but it works a list of notes.
|
||||
"""
|
||||
try:
|
||||
notes = json.loads(self.request_content)
|
||||
except ValueError:
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
if not isinstance(notes, list):
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
for note in notes:
|
||||
note.update({
|
||||
"id": uuid4().hex,
|
||||
"annotator_schema_version": "v1.0",
|
||||
"created": note["created"] if note.get("created") else datetime.utcnow().isoformat(),
|
||||
"updated": note["updated"] if note.get("updated") else datetime.utcnow().isoformat(),
|
||||
})
|
||||
self.server.add_notes(note)
|
||||
|
||||
self.respond(content=notes)
|
||||
|
||||
def _read(self, note_id):
|
||||
"""
|
||||
Return the note by note id.
|
||||
"""
|
||||
notes = self.server.get_notes()
|
||||
result = self.server.filter_by_id(notes, note_id)
|
||||
if result:
|
||||
self.respond(content=result[0])
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _update(self, note_id):
|
||||
"""
|
||||
Update the note by note id.
|
||||
"""
|
||||
note = self.server.update_note(note_id, json.loads(self.request_content))
|
||||
if note:
|
||||
self.respond(content=note)
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _delete(self, note_id):
|
||||
"""
|
||||
Delete the note by note id.
|
||||
"""
|
||||
if self.server.delete_note(note_id):
|
||||
self.respond(204, "No Content")
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _search(self):
|
||||
"""
|
||||
Search for a notes by user id, course_id and usage_id.
|
||||
"""
|
||||
user = self.get_params.get("user", None)
|
||||
usage_id = self.get_params.get("usage_id", None)
|
||||
course_id = self.get_params.get("course_id", None)
|
||||
text = self.get_params.get("text", None)
|
||||
|
||||
if user is None:
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
notes = self.server.get_notes()
|
||||
if course_id is not None:
|
||||
notes = self.server.filter_by_course_id(notes, course_id)
|
||||
if usage_id is not None:
|
||||
notes = self.server.filter_by_usage_id(notes, usage_id)
|
||||
if text:
|
||||
notes = self.server.search(notes, text)
|
||||
self.respond(content={
|
||||
"total": len(notes),
|
||||
"rows": notes,
|
||||
})
|
||||
|
||||
def _collection(self):
|
||||
"""
|
||||
Return all notes for the user.
|
||||
"""
|
||||
user = self.get_params.get("user", None)
|
||||
if user is None:
|
||||
self.send_response(400, content="Bad Request")
|
||||
return
|
||||
notes = self.server.get_notes()
|
||||
self.respond(content=notes)
|
||||
|
||||
def _cleanup(self):
|
||||
"""
|
||||
Helper method that removes all notes to the stub EdxNotes service.
|
||||
"""
|
||||
self.server.cleanup()
|
||||
self.respond()
|
||||
|
||||
|
||||
class StubEdxNotesService(StubHttpService):
|
||||
"""
|
||||
Stub EdxNotes service.
|
||||
"""
|
||||
HANDLER_CLASS = StubEdxNotesServiceHandler
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StubEdxNotesService, self).__init__(*args, **kwargs)
|
||||
self.notes = list()
|
||||
|
||||
def get_notes(self):
|
||||
"""
|
||||
Returns a list of all notes.
|
||||
"""
|
||||
notes = deepcopy(self.notes)
|
||||
notes.reverse()
|
||||
return notes
|
||||
|
||||
def add_notes(self, notes):
|
||||
"""
|
||||
Adds `notes(list)` to the stub EdxNotes service.
|
||||
"""
|
||||
if not isinstance(notes, list):
|
||||
notes = [notes]
|
||||
|
||||
for note in notes:
|
||||
self.notes.append(note)
|
||||
|
||||
def update_note(self, note_id, note_info):
|
||||
"""
|
||||
Updates the note with `note_id(str)` by the `note_info(dict)` to the
|
||||
stub EdxNotes service.
|
||||
"""
|
||||
note = self.filter_by_id(self.notes, note_id)
|
||||
if note:
|
||||
note[0].update(note_info)
|
||||
return note
|
||||
else:
|
||||
return None
|
||||
|
||||
def delete_note(self, note_id):
|
||||
"""
|
||||
Removes the note with `note_id(str)` to the stub EdxNotes service.
|
||||
"""
|
||||
note = self.filter_by_id(self.notes, note_id)
|
||||
if note:
|
||||
index = self.notes.index(note[0])
|
||||
self.notes.pop(index)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Removes all notes to the stub EdxNotes service.
|
||||
"""
|
||||
self.notes = list()
|
||||
|
||||
def filter_by_id(self, data, note_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `note_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "id", note_id)
|
||||
|
||||
def filter_by_user(self, data, user):
|
||||
"""
|
||||
Filters provided `data(list)` by the `user(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "user", user)
|
||||
|
||||
def filter_by_usage_id(self, data, usage_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `usage_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "usage_id", usage_id)
|
||||
|
||||
def filter_by_course_id(self, data, course_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `course_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "course_id", course_id)
|
||||
|
||||
def filter_by(self, data, field_name, value):
|
||||
"""
|
||||
Filters provided `data(list)` by the `field_name(str)` with `value`.
|
||||
"""
|
||||
return [note for note in data if note.get(field_name) == value]
|
||||
|
||||
def search(self, data, query):
|
||||
"""
|
||||
Search the `query(str)` text in the provided `data(list)`.
|
||||
"""
|
||||
return [note for note in data if unicode(query).strip() in note.get("text", "").split()]
|
||||
@@ -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
|
||||
|
||||
189
common/djangoapps/terrain/stubs/tests/test_edxnotes.py
Normal file
189
common/djangoapps/terrain/stubs/tests/test_edxnotes.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
15
common/lib/xmodule/xmodule/edxnotes_utils.py
Normal file
15
common/lib/xmodule/xmodule/edxnotes_utils.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
2
common/static/css/vendor/edxnotes/annotator.min.css
vendored
Normal file
2
common/static/css/vendor/edxnotes/annotator.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
108
common/static/js/spec/logger_spec.js
Normal file
108
common/static/js/spec/logger_spec.js
Normal file
@@ -0,0 +1,108 @@
|
||||
(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);
|
||||
82
common/static/js/src/logger.js
Normal file
82
common/static/js/src/logger.js
Normal file
@@ -0,0 +1,82 @@
|
||||
;(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);
|
||||
26
common/static/js/vendor/edxnotes/annotator-full.min.js
vendored
Normal file
26
common/static/js/vendor/edxnotes/annotator-full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
17
common/templates/edxnotes_wrapper.html
Normal file
17
common/templates/edxnotes_wrapper.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<%! 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')
|
||||
|
||||
76
common/test/acceptance/fixtures/edxnotes.py
Normal file
76
common/test/acceptance/fixtures/edxnotes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
||||
540
common/test/acceptance/pages/lms/edxnotes.py
Normal file
540
common/test/acceptance/pages/lms/edxnotes.py
Normal file
@@ -0,0 +1,540 @@
|
||||
from bok_choy.page_object import PageObject, PageLoadError, unguarded
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from .course_page import CoursePage
|
||||
from ...tests.helpers import disable_animations
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
|
||||
class NoteChild(PageObject):
|
||||
url = None
|
||||
BODY_SELECTOR = None
|
||||
|
||||
def __init__(self, browser, item_id):
|
||||
super(NoteChild, self).__init__(browser)
|
||||
self.item_id = item_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="{}#{}".format(self.BODY_SELECTOR, self.item_id)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `NoteChild` context
|
||||
"""
|
||||
return "{}#{} {}".format(
|
||||
self.BODY_SELECTOR,
|
||||
self.item_id,
|
||||
selector,
|
||||
)
|
||||
|
||||
def _get_element_text(self, selector):
|
||||
element = self.q(css=self._bounded_selector(selector)).first
|
||||
if element:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EdxNotesPageGroup(NoteChild):
|
||||
"""
|
||||
Helper class that works with note groups on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-group"
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(".course-title")
|
||||
|
||||
@property
|
||||
def subtitles(self):
|
||||
return [section.title for section in self.children]
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note-section'))
|
||||
return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class EdxNotesPageSection(NoteChild):
|
||||
"""
|
||||
Helper class that works with note sections on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-section"
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(".course-subtitle")
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note'))
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return [section.text for section in self.children]
|
||||
|
||||
|
||||
class EdxNotesPageItem(NoteChild):
|
||||
"""
|
||||
Helper class that works with note items on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note"
|
||||
UNIT_LINK_SELECTOR = "a.reference-unit-link"
|
||||
|
||||
def go_to_unit(self, unit_page=None):
|
||||
self.q(css=self._bounded_selector(self.UNIT_LINK_SELECTOR)).click()
|
||||
if unit_page is not None:
|
||||
unit_page.wait_for_page()
|
||||
|
||||
@property
|
||||
def unit_name(self):
|
||||
return self._get_element_text(self.UNIT_LINK_SELECTOR)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._get_element_text(".note-comment-p")
|
||||
|
||||
@property
|
||||
def quote(self):
|
||||
return self._get_element_text(".note-excerpt")
|
||||
|
||||
@property
|
||||
def time_updated(self):
|
||||
return self._get_element_text(".reference-updated-date")
|
||||
|
||||
|
||||
class EdxNotesPageView(PageObject):
|
||||
"""
|
||||
Base class for EdxNotes views: Recent Activity, Location in Course, Search Results.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = ".tab-panel"
|
||||
TAB_SELECTOR = ".tab"
|
||||
CHILD_SELECTOR = ".note"
|
||||
CHILD_CLASS = EdxNotesPageItem
|
||||
|
||||
@unguarded
|
||||
def visit(self):
|
||||
"""
|
||||
Open the page containing this page object in the browser.
|
||||
|
||||
Raises:
|
||||
PageLoadError: The page did not load successfully.
|
||||
|
||||
Returns:
|
||||
PageObject
|
||||
"""
|
||||
self.q(css=self.TAB_SELECTOR).first.click()
|
||||
try:
|
||||
return self.wait_for_page()
|
||||
except (BrokenPromise):
|
||||
raise PageLoadError("Timed out waiting to load page '{!r}'".format(self))
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return all([
|
||||
self.q(css="{}".format(self.BODY_SELECTOR)).present,
|
||||
self.q(css="{}.is-active".format(self.TAB_SELECTOR)).present,
|
||||
not self.q(css=".ui-loading").visible,
|
||||
])
|
||||
|
||||
@property
|
||||
def is_closable(self):
|
||||
"""
|
||||
Indicates if tab is closable or not.
|
||||
"""
|
||||
return self.q(css="{} .action-close".format(self.TAB_SELECTOR)).present
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes the tab.
|
||||
"""
|
||||
self.q(css="{} .action-close".format(self.TAB_SELECTOR)).first.click()
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css=self.CHILD_SELECTOR)
|
||||
return [self.CHILD_CLASS(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class RecentActivityView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Recent Activity view.
|
||||
"""
|
||||
BODY_SELECTOR = "#recent-panel"
|
||||
TAB_SELECTOR = ".tab#view-recent-activity"
|
||||
|
||||
|
||||
class CourseStructureView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Location in Course view.
|
||||
"""
|
||||
BODY_SELECTOR = "#structure-panel"
|
||||
TAB_SELECTOR = ".tab#view-course-structure"
|
||||
CHILD_SELECTOR = ".note-group"
|
||||
CHILD_CLASS = EdxNotesPageGroup
|
||||
|
||||
|
||||
class SearchResultsView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Search Results view.
|
||||
"""
|
||||
BODY_SELECTOR = "#search-results-panel"
|
||||
TAB_SELECTOR = ".tab#view-search-results"
|
||||
|
||||
|
||||
class EdxNotesPage(CoursePage):
|
||||
"""
|
||||
EdxNotes page.
|
||||
"""
|
||||
url_path = "edxnotes/"
|
||||
MAPPING = {
|
||||
"recent": RecentActivityView,
|
||||
"structure": CourseStructureView,
|
||||
"search": SearchResultsView,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(EdxNotesPage, self).__init__(*args, **kwargs)
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".wrapper-student-notes").present
|
||||
|
||||
def switch_to_tab(self, tab_name):
|
||||
"""
|
||||
Switches to the appropriate tab `tab_name(str)`.
|
||||
"""
|
||||
self.current_view = self.MAPPING[tab_name](self.browser)
|
||||
self.current_view.visit()
|
||||
|
||||
def close_tab(self, tab_name):
|
||||
"""
|
||||
Closes the tab `tab_name(str)`.
|
||||
"""
|
||||
self.current_view.close()
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def search(self, text):
|
||||
"""
|
||||
Runs search with `text(str)` query.
|
||||
"""
|
||||
self.q(css="#search-notes-form #search-notes-input").first.fill(text)
|
||||
self.q(css='#search-notes-form .search-notes-submit').first.click()
|
||||
# Frontend will automatically switch to Search results tab when search
|
||||
# is running, so the view also needs to be changed.
|
||||
self.current_view = self.MAPPING["search"](self.browser)
|
||||
if text.strip():
|
||||
self.current_view.wait_for_page()
|
||||
|
||||
@property
|
||||
def tabs(self):
|
||||
"""
|
||||
Returns all tabs on the page.
|
||||
"""
|
||||
tabs = self.q(css=".tabs .tab-label")
|
||||
if tabs:
|
||||
return map(lambda x: x.replace("Current tab\n", ""), tabs.text)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_error_visible(self):
|
||||
"""
|
||||
Indicates whether error message is visible or not.
|
||||
"""
|
||||
return self.q(css=".inline-error").visible
|
||||
|
||||
@property
|
||||
def error_text(self):
|
||||
"""
|
||||
Returns error message.
|
||||
"""
|
||||
element = self.q(css=".inline-error").first
|
||||
if element and self.is_error_visible:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css='.note')
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def groups(self):
|
||||
"""
|
||||
Returns all groups on the page.
|
||||
"""
|
||||
children = self.q(css='.note-group')
|
||||
return [EdxNotesPageGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def sections(self):
|
||||
"""
|
||||
Returns all sections on the page.
|
||||
"""
|
||||
children = self.q(css='.note-section')
|
||||
return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def no_content_text(self):
|
||||
"""
|
||||
Returns no content message.
|
||||
"""
|
||||
element = self.q(css=".is-empty").first
|
||||
if element:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EdxNotesUnitPage(CoursePage):
|
||||
"""
|
||||
Page for the Unit with EdxNotes.
|
||||
"""
|
||||
url_path = "courseware/"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="body.courseware .edx-notes-wrapper").present
|
||||
|
||||
def move_mouse_to(self, selector):
|
||||
"""
|
||||
Moves mouse to the element that matches `selector(str)`.
|
||||
"""
|
||||
body = self.q(css=selector)[0]
|
||||
ActionChains(self.browser).move_to_element(body).release().perform()
|
||||
return self
|
||||
|
||||
def click(self, selector):
|
||||
"""
|
||||
Clicks on the element that matches `selector(str)`.
|
||||
"""
|
||||
self.q(css=selector).first.click()
|
||||
return self
|
||||
|
||||
def toggle_visibility(self):
|
||||
"""
|
||||
Clicks on the "Show notes" checkbox.
|
||||
"""
|
||||
self.q(css=".action-toggle-notes").first.click()
|
||||
return self
|
||||
|
||||
@property
|
||||
def components(self):
|
||||
"""
|
||||
Returns a list of annotatable components.
|
||||
"""
|
||||
components = self.q(css=".edx-notes-wrapper")
|
||||
return [AnnotatableComponent(self.browser, component.get_attribute("id")) for component in components]
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns a list of notes for the page.
|
||||
"""
|
||||
notes = []
|
||||
for component in self.components:
|
||||
notes.extend(component.notes)
|
||||
return notes
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refreshes the page and returns a list of annotatable components.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
return self.components
|
||||
|
||||
|
||||
class AnnotatableComponent(NoteChild):
|
||||
"""
|
||||
Helper class that works with annotatable components.
|
||||
"""
|
||||
BODY_SELECTOR = ".edx-notes-wrapper"
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns a list of notes for the component.
|
||||
"""
|
||||
notes = self.q(css=self._bounded_selector(".annotator-hl"))
|
||||
return [EdxNoteHighlight(self.browser, note, self.item_id) for note in notes]
|
||||
|
||||
def create_note(self, selector=".annotate-id"):
|
||||
"""
|
||||
Create the note by the selector, return a context manager that will
|
||||
show and save the note popup.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.select_and_click_adder()
|
||||
yield note
|
||||
note.save()
|
||||
|
||||
def edit_note(self, selector=".annotator-hl"):
|
||||
"""
|
||||
Edit the note by the selector, return a context manager that will
|
||||
show and save the note popup.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.show().edit()
|
||||
yield note
|
||||
note.save()
|
||||
|
||||
def remove_note(self, selector=".annotator-hl"):
|
||||
"""
|
||||
Removes the note by the selector.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.show().remove()
|
||||
|
||||
|
||||
class EdxNoteHighlight(NoteChild):
|
||||
"""
|
||||
Helper class that works with notes.
|
||||
"""
|
||||
BODY_SELECTOR = ""
|
||||
ADDER_SELECTOR = ".annotator-adder"
|
||||
VIEWER_SELECTOR = ".annotator-viewer"
|
||||
EDITOR_SELECTOR = ".annotator-editor"
|
||||
|
||||
def __init__(self, browser, element, parent_id):
|
||||
super(EdxNoteHighlight, self).__init__(browser, parent_id)
|
||||
self.element = element
|
||||
self.item_id = parent_id
|
||||
disable_animations(self)
|
||||
|
||||
@property
|
||||
def is_visible(self):
|
||||
"""
|
||||
Returns True if the note is visible.
|
||||
"""
|
||||
viewer_is_visible = self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).visible
|
||||
editor_is_visible = self.q(css=self._bounded_selector(self.EDITOR_SELECTOR)).visible
|
||||
return viewer_is_visible or editor_is_visible
|
||||
|
||||
def wait_for_adder_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note adder button.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.ADDER_SELECTOR), "Adder is visible."
|
||||
)
|
||||
|
||||
def wait_for_viewer_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note viewer.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.VIEWER_SELECTOR), "Note Viewer is visible."
|
||||
)
|
||||
|
||||
def wait_for_editor_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note editor.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.EDITOR_SELECTOR), "Note Editor is visible."
|
||||
)
|
||||
|
||||
def wait_for_notes_invisibility(self, text="Notes are hidden"):
|
||||
"""
|
||||
Waiting for invisibility of all notes.
|
||||
"""
|
||||
selector = self._bounded_selector(".annotator-outer")
|
||||
self.wait_for_element_invisibility(selector, text)
|
||||
|
||||
def select_and_click_adder(self):
|
||||
"""
|
||||
Creates selection for the element and clicks `add note` button.
|
||||
"""
|
||||
ActionChains(self.browser).double_click(self.element).release().perform()
|
||||
self.wait_for_adder_visibility()
|
||||
self.q(css=self._bounded_selector(self.ADDER_SELECTOR)).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
def click_on_highlight(self):
|
||||
"""
|
||||
Clicks on the highlighted text.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).click().release().perform()
|
||||
return self
|
||||
|
||||
def click_on_viewer(self):
|
||||
"""
|
||||
Clicks on the note viewer.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).first.click()
|
||||
return self
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Hover over highlighted text -> shows note.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).release().perform()
|
||||
self.wait_for_viewer_visibility()
|
||||
return self
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-cancel")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is canceled.")
|
||||
return self
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-save")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is saved.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Clicks delete button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-delete")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is removed.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Clicks edit button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-edit")).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""
|
||||
Returns text of the note.
|
||||
"""
|
||||
self.show()
|
||||
element = self.q(css=self._bounded_selector(".annotator-annotation > div"))
|
||||
if element:
|
||||
text = element.text[0].strip()
|
||||
else:
|
||||
text = None
|
||||
self.q(css=("body")).first.click()
|
||||
self.wait_for_notes_invisibility()
|
||||
return text
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
"""
|
||||
Sets text for the note.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-item textarea")).first.fill(value)
|
||||
@@ -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.
|
||||
|
||||
775
common/test/acceptance/tests/lms/test_lms_edxnotes.py
Normal file
775
common/test/acceptance/tests/lms/test_lms_edxnotes.py
Normal file
@@ -0,0 +1,775 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from unittest import skipUnless
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.lms.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.course_nav import CourseNavPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.edxnotes import EdxNotesUnitPage, EdxNotesPage
|
||||
from ...fixtures.edxnotes import EdxNotesFixture, Note, Range
|
||||
|
||||
|
||||
@skipUnless(os.environ.get("FEATURE_EDXNOTES"), "Requires Student Notes feature to be enabled")
|
||||
class EdxNotesTestMixin(UniqueCourseTest):
|
||||
"""
|
||||
Creates a course with initial data and contains useful helper methods.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize pages and install a course fixture.
|
||||
"""
|
||||
super(EdxNotesTestMixin, self).setUp()
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.course_nav = CourseNavPage(self.browser)
|
||||
self.note_unit_page = EdxNotesUnitPage(self.browser, self.course_id)
|
||||
self.notes_page = EdxNotesPage(self.browser, self.course_id)
|
||||
|
||||
self.username = str(uuid4().hex)[:5]
|
||||
self.email = "{}@email.com".format(self.username)
|
||||
|
||||
self.selector = "annotate-id"
|
||||
self.edxnotes_fixture = EdxNotesFixture()
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info["org"], self.course_info["number"],
|
||||
self.course_info["run"], self.course_info["display_name"]
|
||||
)
|
||||
|
||||
self.course_fixture.add_advanced_settings({
|
||||
u"edxnotes": {u"value": True}
|
||||
})
|
||||
|
||||
self.course_fixture.add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section 1").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 1").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 1").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 1",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
<p>Annotate this text</p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 2",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("vertical", "Test Unit 2").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 3",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 2").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 3").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 4",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("chapter", "Test Section 2").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 3").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 4").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 5",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 6",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
)).install()
|
||||
|
||||
AutoAuthPage(self.browser, username=self.username, email=self.email, course_id=self.course_id).visit()
|
||||
|
||||
def tearDown(self):
|
||||
self.edxnotes_fixture.cleanup()
|
||||
|
||||
def _add_notes(self):
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
notes_list = []
|
||||
for index, xblock in enumerate(xblocks):
|
||||
notes_list.append(
|
||||
Note(
|
||||
user=self.username,
|
||||
usage_id=xblock.locator,
|
||||
course_id=self.course_fixture._course_key,
|
||||
ranges=[Range(startOffset=index, endOffset=index + 5)]
|
||||
)
|
||||
)
|
||||
|
||||
self.edxnotes_fixture.create_notes(notes_list)
|
||||
self.edxnotes_fixture.install()
|
||||
|
||||
|
||||
class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for creation, editing, deleting annotations inside annotatable components in LMS.
|
||||
"""
|
||||
def create_notes(self, components, offset=0):
|
||||
self.assertGreater(len(components), 0)
|
||||
index = offset
|
||||
for component in components:
|
||||
for note in component.create_note(".{}".format(self.selector)):
|
||||
note.text = "TEST TEXT {}".format(index)
|
||||
index += 1
|
||||
|
||||
def edit_notes(self, components, offset=0):
|
||||
self.assertGreater(len(components), 0)
|
||||
index = offset
|
||||
for component in components:
|
||||
self.assertGreater(len(component.notes), 0)
|
||||
for note in component.edit_note():
|
||||
note.text = "TEST TEXT {}".format(index)
|
||||
index += 1
|
||||
|
||||
def remove_notes(self, components):
|
||||
self.assertGreater(len(components), 0)
|
||||
for component in components:
|
||||
self.assertGreater(len(component.notes), 0)
|
||||
component.remove_note()
|
||||
|
||||
def assert_notes_are_removed(self, components):
|
||||
for component in components:
|
||||
self.assertEqual(0, len(component.notes))
|
||||
|
||||
def assert_text_in_notes(self, notes):
|
||||
actual = [note.text for note in notes]
|
||||
expected = ["TEST TEXT {}".format(i) for i in xrange(len(notes))]
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_can_create_notes(self):
|
||||
"""
|
||||
Scenario: User can create notes.
|
||||
Given I have a course with 3 annotatable components
|
||||
And I open the unit with 2 annotatable components
|
||||
When I add 2 notes for the first component and 1 note for the second
|
||||
Then I see that notes were correctly created
|
||||
When I change sequential position to "2"
|
||||
And I add note for the annotatable component on the page
|
||||
Then I see that note was correctly created
|
||||
When I refresh the page
|
||||
Then I see that note was correctly stored
|
||||
When I change sequential position to "1"
|
||||
Then I see that notes were correctly stored on the page
|
||||
"""
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.create_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.create_notes(components)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
def test_can_edit_notes(self):
|
||||
"""
|
||||
Scenario: User can edit notes.
|
||||
Given I have a course with 3 components with notes
|
||||
And I open the unit with 2 annotatable components
|
||||
When I change text in the notes
|
||||
Then I see that notes were correctly changed
|
||||
When I change sequential position to "2"
|
||||
And I change the note on the page
|
||||
Then I see that note was correctly changed
|
||||
When I refresh the page
|
||||
Then I see that edited note was correctly stored
|
||||
When I change sequential position to "1"
|
||||
Then I see that edited notes were correctly stored on the page
|
||||
"""
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.edit_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.edit_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
def test_can_delete_notes(self):
|
||||
"""
|
||||
Scenario: User can delete notes.
|
||||
Given I have a course with 3 components with notes
|
||||
And I open the unit with 2 annotatable components
|
||||
When I remove all notes on the page
|
||||
Then I do not see any notes on the page
|
||||
When I change sequential position to "2"
|
||||
And I remove all notes on the page
|
||||
Then I do not see any notes on the page
|
||||
When I refresh the page
|
||||
Then I do not see any notes on the page
|
||||
When I change sequential position to "1"
|
||||
Then I do not see any notes on the page
|
||||
"""
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.remove_notes(components)
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.remove_notes(components)
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
|
||||
class EdxNotesPageTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for Notes page.
|
||||
"""
|
||||
def _add_notes(self, notes_list):
|
||||
self.edxnotes_fixture.create_notes(notes_list)
|
||||
self.edxnotes_fixture.install()
|
||||
|
||||
def _add_default_notes(self):
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
self._add_notes([
|
||||
Note(
|
||||
usage_id=xblocks[4].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="First note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2011, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[2].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="",
|
||||
quote=u"Annotate this text",
|
||||
updated=datetime(2012, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[0].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Third note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2013, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=18)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[3].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Fourth note",
|
||||
quote="",
|
||||
updated=datetime(2014, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[1].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Fifth note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2015, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
])
|
||||
|
||||
def assertNoteContent(self, item, text=None, quote=None, unit_name=None, time_updated=None):
|
||||
if item.text is not None:
|
||||
self.assertEqual(text, item.text)
|
||||
else:
|
||||
self.assertIsNone(text)
|
||||
if item.quote is not None:
|
||||
self.assertIn(quote, item.quote)
|
||||
else:
|
||||
self.assertIsNone(quote)
|
||||
self.assertEqual(unit_name, item.unit_name)
|
||||
self.assertEqual(time_updated, item.time_updated)
|
||||
|
||||
def assertGroupContent(self, item, title=None, subtitles=None):
|
||||
self.assertEqual(item.title, title)
|
||||
self.assertEqual(item.subtitles, subtitles)
|
||||
|
||||
def assertSectionContent(self, item, title=None, notes=None):
|
||||
self.assertEqual(item.title, title)
|
||||
self.assertEqual(item.notes, notes)
|
||||
|
||||
def test_no_content(self):
|
||||
"""
|
||||
Scenario: User can see `No content` message.
|
||||
Given I have a course without notes
|
||||
When I open Notes page
|
||||
Then I see only "You do not have any notes within the course." message
|
||||
"""
|
||||
self.notes_page.visit()
|
||||
self.assertIn(
|
||||
"You have not made any notes in this course yet. Other students in this course are using notes to:",
|
||||
self.notes_page.no_content_text)
|
||||
|
||||
def test_recent_activity_view(self):
|
||||
"""
|
||||
Scenario: User can view all notes by recent activity.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
Then I see 5 notes sorted by the updated date
|
||||
And I see correct content in the notes
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
notes = self.notes_page.notes
|
||||
self.assertEqual(len(notes), 5)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[0],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Fifth note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2015 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[1],
|
||||
text=u"Fourth note",
|
||||
unit_name="Test Unit 3",
|
||||
time_updated="Jan 01, 2014 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[2],
|
||||
quote="Annotate this text",
|
||||
text=u"Third note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2013 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[3],
|
||||
quote=u"Annotate this text",
|
||||
unit_name="Test Unit 2",
|
||||
time_updated="Jan 01, 2012 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[4],
|
||||
quote=u"Annotate this text",
|
||||
text=u"First note",
|
||||
unit_name="Test Unit 4",
|
||||
time_updated="Jan 01, 2011 at 01:01 UTC"
|
||||
)
|
||||
|
||||
def test_course_structure_view(self):
|
||||
"""
|
||||
Scenario: User can view all notes by location in Course.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
And I switch to "Location in Course" view
|
||||
Then I see 2 groups, 3 sections and 5 notes
|
||||
And I see correct content in the notes and groups
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit().switch_to_tab("structure")
|
||||
|
||||
notes = self.notes_page.notes
|
||||
groups = self.notes_page.groups
|
||||
sections = self.notes_page.sections
|
||||
self.assertEqual(len(notes), 5)
|
||||
self.assertEqual(len(groups), 2)
|
||||
self.assertEqual(len(sections), 3)
|
||||
|
||||
self.assertGroupContent(
|
||||
groups[0],
|
||||
title=u"Test Section 1",
|
||||
subtitles=[u"Test Subsection 1", u"Test Subsection 2"]
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[0],
|
||||
title=u"Test Subsection 1",
|
||||
notes=[u"Fifth note", u"Third note", None]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[0],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Fifth note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2015 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[1],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Third note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2013 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[2],
|
||||
quote=u"Annotate this text",
|
||||
unit_name="Test Unit 2",
|
||||
time_updated="Jan 01, 2012 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[1],
|
||||
title=u"Test Subsection 2",
|
||||
notes=[u"Fourth note"]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[3],
|
||||
text=u"Fourth note",
|
||||
unit_name="Test Unit 3",
|
||||
time_updated="Jan 01, 2014 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertGroupContent(
|
||||
groups[1],
|
||||
title=u"Test Section 2",
|
||||
subtitles=[u"Test Subsection 3"],
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[2],
|
||||
title=u"Test Subsection 3",
|
||||
notes=[u"First note"]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[4],
|
||||
quote=u"Annotate this text",
|
||||
text=u"First note",
|
||||
unit_name="Test Unit 4",
|
||||
time_updated="Jan 01, 2011 at 01:01 UTC"
|
||||
)
|
||||
|
||||
def test_easy_access_from_notes_page(self):
|
||||
"""
|
||||
Scenario: Ensure that the link to the Unit works correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
And I click on the first unit link
|
||||
Then I see correct text on the unit page
|
||||
When go back to the Notes page
|
||||
And I switch to "Location in Course" view
|
||||
And I click on the second unit link
|
||||
Then I see correct text on the unit page
|
||||
When go back to the Notes page
|
||||
And I run the search with "Fifth" query
|
||||
And I click on the first unit link
|
||||
Then I see correct text on the unit page
|
||||
"""
|
||||
def assert_page(note):
|
||||
quote = note.quote
|
||||
note.go_to_unit()
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertIn(quote, self.courseware_page.xblock_component_html_content())
|
||||
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
note = self.notes_page.notes[0]
|
||||
assert_page(note)
|
||||
|
||||
self.notes_page.visit().switch_to_tab("structure")
|
||||
note = self.notes_page.notes[1]
|
||||
assert_page(note)
|
||||
|
||||
self.notes_page.visit().search("Fifth")
|
||||
note = self.notes_page.notes[0]
|
||||
assert_page(note)
|
||||
|
||||
def test_search_behaves_correctly(self):
|
||||
"""
|
||||
Scenario: Searching behaves correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
When I run the search with " " query
|
||||
Then I see the following error message "Please enter a term in the search field."
|
||||
And I do not see "Search Results" tab
|
||||
When I run the search with "note" query
|
||||
Then I see that error message disappears
|
||||
And I see that "Search Results" tab appears with 4 notes found
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
# Run the search with whitespaces only
|
||||
self.notes_page.search(" ")
|
||||
# Displays error message
|
||||
self.assertTrue(self.notes_page.is_error_visible)
|
||||
self.assertEqual(self.notes_page.error_text, u"Please enter a term in the search field.")
|
||||
# Search results tab does not appear
|
||||
self.assertNotIn(u"Search Results", self.notes_page.tabs)
|
||||
# Run the search with correct query
|
||||
self.notes_page.search("note")
|
||||
# Error message disappears
|
||||
self.assertFalse(self.notes_page.is_error_visible)
|
||||
self.assertIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
|
||||
def test_tabs_behaves_correctly(self):
|
||||
"""
|
||||
Scenario: Tabs behaves correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
Then I see only "Recent Activity" and "Location in Course" tabs
|
||||
When I run the search with "note" query
|
||||
And I see that "Search Results" tab appears with 4 notes found
|
||||
Then I switch to "Recent Activity" tab
|
||||
And I see all 5 notes
|
||||
Then I switch to "Location in Course" tab
|
||||
And I see all 2 groups and 5 notes
|
||||
When I switch back to "Search Results" tab
|
||||
Then I can still see 4 notes found
|
||||
When I close "Search Results" tab
|
||||
Then I see that "Recent Activity" tab becomes active
|
||||
And "Search Results" tab disappears
|
||||
And I see all 5 notes
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
|
||||
# We're on Recent Activity tab.
|
||||
self.assertEqual(len(self.notes_page.tabs), 2)
|
||||
self.assertEqual([u"Recent Activity", u"Location in Course"], self.notes_page.tabs)
|
||||
self.notes_page.search("note")
|
||||
# We're on Search Results tab
|
||||
self.assertEqual(len(self.notes_page.tabs), 3)
|
||||
self.assertIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
# We can switch on Recent Activity tab and back.
|
||||
self.notes_page.switch_to_tab("recent")
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
self.notes_page.switch_to_tab("structure")
|
||||
self.assertEqual(len(self.notes_page.groups), 2)
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
self.notes_page.switch_to_tab("search")
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
# Can close search results page
|
||||
self.notes_page.close_tab("search")
|
||||
self.assertEqual(len(self.notes_page.tabs), 2)
|
||||
self.assertNotIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
|
||||
def test_open_note_when_accessed_from_notes_page(self):
|
||||
"""
|
||||
Scenario: Ensure that the link to the Unit opens a note only once.
|
||||
Given I have a course with 2 sequentials that contain respectively one note and two notes
|
||||
When I open Notes page
|
||||
And I click on the first unit link
|
||||
Then I see the note opened on the unit page
|
||||
When I switch to the second sequential
|
||||
I do not see any note opened
|
||||
When I switch back to first sequential
|
||||
I do not see any note opened
|
||||
"""
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
self._add_notes([
|
||||
Note(
|
||||
usage_id=xblocks[1].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Third note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2012, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[2].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Second note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2013, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[0].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="First note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2014, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
])
|
||||
self.notes_page.visit()
|
||||
item = self.notes_page.notes[0]
|
||||
item.go_to_unit()
|
||||
self.courseware_page.wait_for_page()
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertTrue(note.is_visible)
|
||||
note = self.note_unit_page.notes[1]
|
||||
self.assertFalse(note.is_visible)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertFalse(note.is_visible)
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
|
||||
class EdxNotesToggleSingleNoteTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for toggling single annotation.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EdxNotesToggleSingleNoteTest, self).setUp()
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
def test_can_toggle_by_clicking_on_highlighted_text(self):
|
||||
"""
|
||||
Scenario: User can toggle a single note by clicking on highlighted text.
|
||||
Given I have a course with components with notes
|
||||
When I click on highlighted text
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click outside the note
|
||||
Then I see the the note is closed
|
||||
"""
|
||||
note = self.note_unit_page.notes[0]
|
||||
|
||||
note.click_on_highlight()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note.is_visible)
|
||||
self.note_unit_page.click("body")
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
def test_can_toggle_by_clicking_on_the_note(self):
|
||||
"""
|
||||
Scenario: User can toggle a single note by clicking on the note.
|
||||
Given I have a course with components with notes
|
||||
When I click on the note
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click outside the note
|
||||
Then I see the the note is closed
|
||||
"""
|
||||
note = self.note_unit_page.notes[0]
|
||||
|
||||
note.show().click_on_viewer()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note.is_visible)
|
||||
self.note_unit_page.click("body")
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
def test_interaction_between_notes(self):
|
||||
"""
|
||||
Scenario: Interactions between notes works well.
|
||||
Given I have a course with components with notes
|
||||
When I click on highlighted text in the first component
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click on highlighted text in the second component
|
||||
Then I do not see any notes
|
||||
When I click again on highlighted text in the second component
|
||||
Then I see appropriate note
|
||||
"""
|
||||
note_1 = self.note_unit_page.notes[0]
|
||||
note_2 = self.note_unit_page.notes[1]
|
||||
|
||||
note_1.click_on_highlight()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note_1.is_visible)
|
||||
|
||||
note_2.click_on_highlight()
|
||||
self.assertFalse(note_1.is_visible)
|
||||
self.assertFalse(note_2.is_visible)
|
||||
|
||||
note_2.click_on_highlight()
|
||||
self.assertTrue(note_2.is_visible)
|
||||
|
||||
|
||||
class EdxNotesToggleNotesTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for toggling visibility of all notes.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EdxNotesToggleNotesTest, self).setUp()
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
def test_can_disable_all_notes(self):
|
||||
"""
|
||||
Scenario: User can disable all notes.
|
||||
Given I have a course with components with notes
|
||||
And I open the unit with annotatable components
|
||||
When I click on "Show notes" checkbox
|
||||
Then I do not see any notes on the sequential position
|
||||
When I change sequential position to "2"
|
||||
Then I still do not see any notes on the sequential position
|
||||
When I go to "Test Subsection 2" subsection
|
||||
Then I do not see any notes on the subsection
|
||||
"""
|
||||
# Disable all notes
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
|
||||
def test_can_reenable_all_notes(self):
|
||||
"""
|
||||
Scenario: User can toggle notes visibility.
|
||||
Given I have a course with components with notes
|
||||
And I open the unit with annotatable components
|
||||
When I click on "Show notes" checkbox
|
||||
Then I do not see any notes on the sequential position
|
||||
When I click on "Show notes" checkbox again
|
||||
Then I see that all notes appear
|
||||
When I change sequential position to "2"
|
||||
Then I still can see all notes on the sequential position
|
||||
When I go to "Test Subsection 2" subsection
|
||||
Then I can see all notes on the subsection
|
||||
"""
|
||||
# Disable notes
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
# Enable notes to make sure that I can enable notes without refreshing
|
||||
# the page.
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
15
common/test/db_fixtures/edx-notes_client.json
Normal file
15
common/test/db_fixtures/edx-notes_client.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
0
lms/djangoapps/edxnotes/__init__.py
Normal file
0
lms/djangoapps/edxnotes/__init__.py
Normal file
54
lms/djangoapps/edxnotes/decorators.py
Normal file
54
lms/djangoapps/edxnotes/decorators.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
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
|
||||
17
lms/djangoapps/edxnotes/exceptions.py
Normal file
17
lms/djangoapps/edxnotes/exceptions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
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
|
||||
401
lms/djangoapps/edxnotes/helpers.py
Normal file
401
lms/djangoapps/edxnotes/helpers.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Helper methods related to EdxNotes.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
from uuid import uuid4
|
||||
from json import JSONEncoder
|
||||
from datetime import datetime
|
||||
from courseware.access import has_access
|
||||
from courseware.views import get_current_child
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from capa.util import sanitize_html
|
||||
from student.models import anonymous_id_for_user
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from util.date_utils import get_default_time_display
|
||||
from dateutil.parser import parse as dateutil_parse
|
||||
from provider.oauth2.models import AccessToken, Client
|
||||
import oauth2_provider.oidc as oidc
|
||||
from provider.utils import now
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
HIGHLIGHT_TAG = "span"
|
||||
HIGHLIGHT_CLASS = "note-highlight"
|
||||
|
||||
|
||||
class NoteJSONEncoder(JSONEncoder):
|
||||
"""
|
||||
Custom JSON encoder that encode datetime objects to appropriate time strings.
|
||||
"""
|
||||
# pylint: disable=method-hidden
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return get_default_time_display(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def get_id_token(user):
|
||||
"""
|
||||
Generates JWT ID-Token, using or creating user's OAuth access token.
|
||||
"""
|
||||
try:
|
||||
client = Client.objects.get(name="edx-notes")
|
||||
except Client.DoesNotExist:
|
||||
raise ImproperlyConfigured("OAuth2 Client with name 'edx-notes' is not present in the DB")
|
||||
try:
|
||||
access_token = AccessToken.objects.get(
|
||||
client=client,
|
||||
user=user,
|
||||
expires__gt=now()
|
||||
)
|
||||
except AccessToken.DoesNotExist:
|
||||
access_token = AccessToken(client=client, user=user)
|
||||
access_token.save()
|
||||
|
||||
id_token = oidc.id_token(access_token)
|
||||
secret = id_token.access_token.client.client_secret
|
||||
return id_token.encode(secret)
|
||||
|
||||
|
||||
def get_token_url(course_id):
|
||||
"""
|
||||
Returns token url for the course.
|
||||
"""
|
||||
return reverse("get_token", kwargs={
|
||||
"course_id": unicode(course_id),
|
||||
})
|
||||
|
||||
|
||||
def send_request(user, course_id, path="", query_string=None):
|
||||
"""
|
||||
Sends a request with appropriate parameters and headers.
|
||||
"""
|
||||
url = get_endpoint(path)
|
||||
params = {
|
||||
"user": anonymous_id_for_user(user, None),
|
||||
"course_id": unicode(course_id).encode("utf-8"),
|
||||
}
|
||||
|
||||
if query_string:
|
||||
params.update({
|
||||
"text": query_string,
|
||||
"highlight": True,
|
||||
"highlight_tag": HIGHLIGHT_TAG,
|
||||
"highlight_class": HIGHLIGHT_CLASS,
|
||||
})
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={
|
||||
"x-annotator-auth-token": get_id_token(user)
|
||||
},
|
||||
params=params
|
||||
)
|
||||
except RequestException:
|
||||
raise EdxNotesServiceUnavailable(_("EdxNotes Service is unavailable. Please try again in a few minutes."))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_parent_xblock(xblock):
|
||||
"""
|
||||
Returns the xblock that is the parent of the specified xblock, or None if it has no parent.
|
||||
"""
|
||||
# TODO: replace with xblock.get_parent() when it lands
|
||||
store = modulestore()
|
||||
if store.request_cache is not None:
|
||||
parent_cache = store.request_cache.data.setdefault('edxnotes-parent-cache', {})
|
||||
else:
|
||||
parent_cache = None
|
||||
|
||||
locator = xblock.location
|
||||
if parent_cache and unicode(locator) in parent_cache:
|
||||
return parent_cache[unicode(locator)]
|
||||
|
||||
parent_location = store.get_parent_location(locator)
|
||||
|
||||
if parent_location is None:
|
||||
return None
|
||||
xblock = store.get_item(parent_location)
|
||||
# .get_parent_location(locator) returns locators w/o branch and version
|
||||
# and for uniformity we remove them from children locators
|
||||
xblock.children = [child.for_branch(None) for child in xblock.children]
|
||||
|
||||
if parent_cache is not None:
|
||||
for child in xblock.children:
|
||||
parent_cache[unicode(child)] = xblock
|
||||
|
||||
return xblock
|
||||
|
||||
|
||||
def get_parent_unit(xblock):
|
||||
"""
|
||||
Find vertical that is a unit, not just some container.
|
||||
"""
|
||||
while xblock:
|
||||
xblock = get_parent_xblock(xblock)
|
||||
if xblock is None:
|
||||
return None
|
||||
parent = get_parent_xblock(xblock)
|
||||
if parent is None:
|
||||
return None
|
||||
if parent.category == 'sequential':
|
||||
return xblock
|
||||
|
||||
|
||||
def preprocess_collection(user, course, collection):
|
||||
"""
|
||||
Prepare `collection(notes_list)` provided by edx-notes-api
|
||||
for rendering in a template:
|
||||
add information about ancestor blocks,
|
||||
convert "updated" to date
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError - when appropriate module is not found.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
store = modulestore()
|
||||
filtered_collection = list()
|
||||
cache = {}
|
||||
with store.bulk_operations(course.id):
|
||||
for model in collection:
|
||||
model.update({
|
||||
u"text": sanitize_html(model["text"]),
|
||||
u"quote": sanitize_html(model["quote"]),
|
||||
u"updated": dateutil_parse(model["updated"]),
|
||||
})
|
||||
usage_id = model["usage_id"]
|
||||
if usage_id in cache:
|
||||
model.update(cache[usage_id])
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
usage_key = UsageKey.from_string(usage_id)
|
||||
# Add a course run if necessary.
|
||||
usage_key = usage_key.replace(course_key=store.fill_in_run(usage_key.course_key))
|
||||
|
||||
try:
|
||||
item = store.get_item(usage_key)
|
||||
except ItemNotFoundError:
|
||||
log.debug("Module not found: %s", usage_key)
|
||||
continue
|
||||
|
||||
if not has_access(user, "load", item, course_key=course.id):
|
||||
log.debug("User %s does not have an access to %s", user, item)
|
||||
continue
|
||||
|
||||
unit = get_parent_unit(item)
|
||||
if unit is None:
|
||||
log.debug("Unit not found: %s", usage_key)
|
||||
continue
|
||||
|
||||
section = get_parent_xblock(unit)
|
||||
if not section:
|
||||
log.debug("Section not found: %s", usage_key)
|
||||
continue
|
||||
if section in cache:
|
||||
usage_context = cache[section]
|
||||
usage_context.update({
|
||||
"unit": get_module_context(course, unit),
|
||||
})
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = usage_context
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
chapter = get_parent_xblock(section)
|
||||
if not chapter:
|
||||
log.debug("Chapter not found: %s", usage_key)
|
||||
continue
|
||||
if chapter in cache:
|
||||
usage_context = cache[chapter]
|
||||
usage_context.update({
|
||||
"unit": get_module_context(course, unit),
|
||||
"section": get_module_context(course, section),
|
||||
})
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = cache[section] = usage_context
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
usage_context = {
|
||||
"unit": get_module_context(course, unit),
|
||||
"section": get_module_context(course, section),
|
||||
"chapter": get_module_context(course, chapter),
|
||||
}
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = cache[section] = cache[chapter] = usage_context
|
||||
filtered_collection.append(model)
|
||||
|
||||
return filtered_collection
|
||||
|
||||
|
||||
def get_module_context(course, item):
|
||||
"""
|
||||
Returns dispay_name and url for the parent module.
|
||||
"""
|
||||
item_dict = {
|
||||
'location': unicode(item.location),
|
||||
'display_name': item.display_name_with_default,
|
||||
}
|
||||
if item.category == 'chapter':
|
||||
item_dict['index'] = get_index(item_dict['location'], course.children)
|
||||
elif item.category == 'vertical':
|
||||
section = get_parent_xblock(item)
|
||||
chapter = get_parent_xblock(section)
|
||||
# Position starts from 1, that's why we add 1.
|
||||
position = get_index(unicode(item.location), section.children) + 1
|
||||
item_dict['url'] = reverse('courseware_position', kwargs={
|
||||
'course_id': unicode(course.id),
|
||||
'chapter': chapter.url_name,
|
||||
'section': section.url_name,
|
||||
'position': position,
|
||||
})
|
||||
if item.category in ('chapter', 'sequential'):
|
||||
item_dict['children'] = [unicode(child) for child in item.children]
|
||||
|
||||
return item_dict
|
||||
|
||||
|
||||
def get_index(usage_key, children):
|
||||
"""
|
||||
Returns an index of the child with `usage_key`.
|
||||
"""
|
||||
children = [unicode(child) for child in children]
|
||||
return children.index(usage_key)
|
||||
|
||||
|
||||
def search(user, course, query_string):
|
||||
"""
|
||||
Returns search results for the `query_string(str)`.
|
||||
"""
|
||||
response = send_request(user, course.id, "search", query_string)
|
||||
try:
|
||||
content = json.loads(response.content)
|
||||
collection = content["rows"]
|
||||
except (ValueError, KeyError):
|
||||
log.warning("invalid JSON: %s", response.content)
|
||||
raise EdxNotesParseError(_("Server error. Please try again in a few minutes."))
|
||||
|
||||
content.update({
|
||||
"rows": preprocess_collection(user, course, collection)
|
||||
})
|
||||
|
||||
return json.dumps(content, cls=NoteJSONEncoder)
|
||||
|
||||
|
||||
def get_notes(user, course):
|
||||
"""
|
||||
Returns all notes for the user.
|
||||
"""
|
||||
response = send_request(user, course.id, "annotations")
|
||||
try:
|
||||
collection = json.loads(response.content)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not collection:
|
||||
return None
|
||||
|
||||
return json.dumps(preprocess_collection(user, course, collection), cls=NoteJSONEncoder)
|
||||
|
||||
|
||||
def get_endpoint(path=""):
|
||||
"""
|
||||
Returns edx-notes-api endpoint.
|
||||
"""
|
||||
try:
|
||||
url = settings.EDXNOTES_INTERFACE['url']
|
||||
if not url.endswith("/"):
|
||||
url += "/"
|
||||
|
||||
if path:
|
||||
if path.startswith("/"):
|
||||
path = path.lstrip("/")
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
|
||||
return url + path
|
||||
except (AttributeError, KeyError):
|
||||
raise ImproperlyConfigured(_("No endpoint was provided for EdxNotes."))
|
||||
|
||||
|
||||
def get_course_position(course_module):
|
||||
"""
|
||||
Return the user's current place in the course.
|
||||
|
||||
If this is the user's first time, leads to COURSE/CHAPTER/SECTION.
|
||||
If this isn't the users's first time, leads to COURSE/CHAPTER.
|
||||
|
||||
If there is no current position in the course or chapter, then selects
|
||||
the first child.
|
||||
"""
|
||||
urlargs = {'course_id': unicode(course_module.id)}
|
||||
chapter = get_current_child(course_module, min_depth=1)
|
||||
if chapter is None:
|
||||
log.debug("No chapter found when loading current position in course")
|
||||
return None
|
||||
|
||||
urlargs['chapter'] = chapter.url_name
|
||||
if course_module.position is not None:
|
||||
return {
|
||||
'display_name': chapter.display_name_with_default,
|
||||
'url': reverse('courseware_chapter', kwargs=urlargs),
|
||||
}
|
||||
|
||||
# Relying on default of returning first child
|
||||
section = get_current_child(chapter, min_depth=1)
|
||||
if section is None:
|
||||
log.debug("No section found when loading current position in course")
|
||||
return None
|
||||
|
||||
urlargs['section'] = section.url_name
|
||||
return {
|
||||
'display_name': section.display_name_with_default,
|
||||
'url': reverse('courseware_section', kwargs=urlargs)
|
||||
}
|
||||
|
||||
|
||||
def generate_uid():
|
||||
"""
|
||||
Generates unique id.
|
||||
"""
|
||||
return uuid4().int # pylint: disable=no-member
|
||||
|
||||
|
||||
def is_feature_enabled(course):
|
||||
"""
|
||||
Returns True if Student Notes feature is enabled for the course,
|
||||
False otherwise.
|
||||
|
||||
In order for the application to be enabled it must be:
|
||||
1) enabled globally via FEATURES.
|
||||
2) present in the course tab configuration.
|
||||
3) Harvard Annotation Tool must be disabled for the course.
|
||||
"""
|
||||
return (settings.FEATURES.get("ENABLE_EDXNOTES")
|
||||
and [t for t in course.tabs if t["type"] == "edxnotes"] # tab found
|
||||
and not is_harvard_notes_enabled(course))
|
||||
|
||||
|
||||
def is_harvard_notes_enabled(course):
|
||||
"""
|
||||
Returns True if Harvard Annotation Tool is enabled for the course,
|
||||
False otherwise.
|
||||
|
||||
Checks for 'textannotation', 'imageannotation', 'videoannotation' in the list
|
||||
of advanced modules of the course.
|
||||
"""
|
||||
modules = set(['textannotation', 'imageannotation', 'videoannotation'])
|
||||
return bool(modules.intersection(course.advanced_modules))
|
||||
975
lms/djangoapps/edxnotes/tests.py
Normal file
975
lms/djangoapps/edxnotes/tests.py
Normal file
@@ -0,0 +1,975 @@
|
||||
"""
|
||||
Tests for the EdxNotes app.
|
||||
"""
|
||||
import json
|
||||
import jwt
|
||||
from mock import patch, MagicMock
|
||||
from unittest import skipUnless
|
||||
from datetime import datetime
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from edxnotes import helpers
|
||||
from edxnotes.decorators import edxnotes
|
||||
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.oauth2.models import Client
|
||||
from xmodule.tabs import EdxNotesTab
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module_for_descriptor
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
def enable_edxnotes_for_the_course(course, user_id):
|
||||
"""
|
||||
Enable EdxNotes for the course.
|
||||
"""
|
||||
course.tabs.append(EdxNotesTab())
|
||||
modulestore().update_item(course, user_id)
|
||||
|
||||
|
||||
@edxnotes
|
||||
class TestProblem(object):
|
||||
"""
|
||||
Test class (fake problem) decorated by edxnotes decorator.
|
||||
|
||||
The purpose of this class is to imitate any problem.
|
||||
"""
|
||||
def __init__(self, course):
|
||||
self.system = MagicMock(is_author_mode=False)
|
||||
self.scope_ids = MagicMock(usage_id="test_usage_id")
|
||||
self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx")
|
||||
self.runtime = MagicMock(course_id=course.id, get_real_user=lambda anon_id: self.user)
|
||||
self.descriptor = MagicMock()
|
||||
self.descriptor.runtime.modulestore.get_course.return_value = course
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Imitate get_html in module.
|
||||
"""
|
||||
return "original_get_html"
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesDecoratorTest(TestCase):
|
||||
"""
|
||||
Tests for edxnotes decorator.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
ClientFactory(name="edx-notes")
|
||||
self.course = CourseFactory.create(edxnotes=True)
|
||||
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
self.problem = TestProblem(self.course)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True})
|
||||
@patch("edxnotes.decorators.get_endpoint")
|
||||
@patch("edxnotes.decorators.get_token_url")
|
||||
@patch("edxnotes.decorators.get_id_token")
|
||||
@patch("edxnotes.decorators.generate_uid")
|
||||
def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_token_url, mock_get_endpoint):
|
||||
"""
|
||||
Tests if get_html is wrapped when feature flag is on and edxnotes are
|
||||
enabled for the course.
|
||||
"""
|
||||
mock_generate_uid.return_value = "uid"
|
||||
mock_get_id_token.return_value = "token"
|
||||
mock_get_token_url.return_value = "/tokenUrl"
|
||||
mock_get_endpoint.return_value = "/endpoint"
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
expected_context = {
|
||||
"content": "original_get_html",
|
||||
"uid": "uid",
|
||||
"edxnotes_visibility": "true",
|
||||
"params": {
|
||||
"usageId": u"test_usage_id",
|
||||
"courseId": unicode(self.course.id).encode("utf-8"),
|
||||
"token": "token",
|
||||
"tokenUrl": "/tokenUrl",
|
||||
"endpoint": "/endpoint",
|
||||
"debug": settings.DEBUG,
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
self.problem.get_html(),
|
||||
render_to_string("edxnotes_wrapper.html", expected_context),
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_disabled_if_edxnotes_flag_is_false(self):
|
||||
"""
|
||||
Tests that get_html is wrapped when feature flag is on, but edxnotes are
|
||||
disabled for the course.
|
||||
"""
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_disabled(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when feature flag is off.
|
||||
"""
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
def test_edxnotes_studio(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when problem is rendered in Studio.
|
||||
"""
|
||||
self.problem.system.is_author_mode = True
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
def test_edxnotes_harvard_notes_enabled(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when Harvard Annotation Tool is enabled.
|
||||
"""
|
||||
self.course.advanced_modules = ["videoannotation", "imageannotation", "textannotation"]
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesHelpersTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for EdxNotes helpers.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup a dummy course content.
|
||||
"""
|
||||
super(EdxNotesHelpersTest, self).setUp()
|
||||
modulestore().request_cache.data = {}
|
||||
ClientFactory(name="edx-notes")
|
||||
self.course = CourseFactory.create()
|
||||
self.chapter = ItemFactory.create(category="chapter", parent_location=self.course.location)
|
||||
self.chapter_2 = ItemFactory.create(category="chapter", parent_location=self.course.location)
|
||||
self.sequential = ItemFactory.create(category="sequential", parent_location=self.chapter.location)
|
||||
self.vertical = ItemFactory.create(category="vertical", parent_location=self.sequential.location)
|
||||
self.html_module_1 = ItemFactory.create(category="html", parent_location=self.vertical.location)
|
||||
self.html_module_2 = ItemFactory.create(category="html", parent_location=self.vertical.location)
|
||||
self.vertical_with_container = ItemFactory.create(category='vertical', parent_location=self.sequential.location)
|
||||
self.child_container = ItemFactory.create(
|
||||
category='split_test', parent_location=self.vertical_with_container.location)
|
||||
self.child_vertical = ItemFactory.create(category='vertical', parent_location=self.child_container.location)
|
||||
self.child_html_module = ItemFactory.create(category="html", parent_location=self.child_vertical.location)
|
||||
|
||||
# Read again so that children lists are accurate
|
||||
self.course = self.store.get_item(self.course.location)
|
||||
self.chapter = self.store.get_item(self.chapter.location)
|
||||
self.chapter_2 = self.store.get_item(self.chapter_2.location)
|
||||
self.sequential = self.store.get_item(self.sequential.location)
|
||||
self.vertical = self.store.get_item(self.vertical.location)
|
||||
|
||||
self.vertical_with_container = self.store.get_item(self.vertical_with_container.location)
|
||||
self.child_container = self.store.get_item(self.child_container.location)
|
||||
self.child_vertical = self.store.get_item(self.child_vertical.location)
|
||||
self.child_html_module = self.store.get_item(self.child_html_module.location)
|
||||
|
||||
self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
def _get_unit_url(self, course, chapter, section, position=1):
|
||||
"""
|
||||
Returns `jump_to_id` url for the `vertical`.
|
||||
"""
|
||||
return reverse('courseware_position', kwargs={
|
||||
'course_id': course.id,
|
||||
'chapter': chapter.url_name,
|
||||
'section': section.url_name,
|
||||
'position': position,
|
||||
})
|
||||
|
||||
def test_edxnotes_not_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are disabled when the course tab configuration does NOT
|
||||
contain a tab with type "edxnotes."
|
||||
"""
|
||||
self.course.tabs = []
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_edxnotes_harvard_notes_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are disabled when Harvard Annotation Tool is enabled.
|
||||
"""
|
||||
self.course.advanced_modules = ["foo", "imageannotation", "boo"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["foo", "boo", "videoannotation"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["textannotation", "foo", "boo"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["textannotation", "videoannotation", "imageannotation"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_edxnotes_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are enabled when the course tab configuration contains
|
||||
a tab with type "edxnotes."
|
||||
"""
|
||||
self.course.tabs = [{"type": "foo"},
|
||||
{"name": "Notes", "type": "edxnotes"},
|
||||
{"type": "bar"}]
|
||||
self.assertTrue(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_get_endpoint(self):
|
||||
"""
|
||||
Tests that storage_url method returns appropriate values.
|
||||
"""
|
||||
# url ends with "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com/"}):
|
||||
self.assertEqual("http://example.com/", helpers.get_endpoint())
|
||||
|
||||
# url doesn't have "/" at the end
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/", helpers.get_endpoint())
|
||||
|
||||
# url with path that starts with "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("/some_path"))
|
||||
|
||||
# url with path without "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("some_path/"))
|
||||
|
||||
# url is not configured
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": None}):
|
||||
self.assertRaises(ImproperlyConfigured, helpers.get_endpoint)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_correct_data(self, mock_get):
|
||||
"""
|
||||
Tests the result if correct data is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps([
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
}
|
||||
])
|
||||
|
||||
self.assertItemsEqual(
|
||||
[
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": "Nov 19, 2014 at 08:06 UTC",
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": "Nov 19, 2014 at 08:05 UTC",
|
||||
},
|
||||
],
|
||||
json.loads(helpers.get_notes(self.user, self.course))
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_json_error(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect json is received.
|
||||
"""
|
||||
mock_get.return_value.content = "Error"
|
||||
self.assertIsNone(helpers.get_notes(self.user, self.course))
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_empty_collection(self, mock_get):
|
||||
"""
|
||||
Tests the result if an empty collection is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps([])
|
||||
self.assertIsNone(helpers.get_notes(self.user, self.course))
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_correct_data(self, mock_get):
|
||||
"""
|
||||
Tests the result if correct data is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({
|
||||
"total": 2,
|
||||
"rows": [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
self.assertItemsEqual(
|
||||
{
|
||||
"total": 2,
|
||||
"rows": [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": "Nov 19, 2014 at 08:06 UTC",
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": "Nov 19, 2014 at 08:05 UTC",
|
||||
},
|
||||
]
|
||||
},
|
||||
json.loads(helpers.search(self.user, self.course, "test"))
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_json_error(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect json is received.
|
||||
"""
|
||||
mock_get.return_value.content = "Error"
|
||||
self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test")
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_wrong_data_format(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect data structure is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({"1": 2})
|
||||
self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test")
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_empty_collection(self, mock_get):
|
||||
"""
|
||||
Tests no results.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({
|
||||
"total": 0,
|
||||
"rows": []
|
||||
})
|
||||
self.assertItemsEqual(
|
||||
{
|
||||
"total": 0,
|
||||
"rows": []
|
||||
},
|
||||
json.loads(helpers.search(self.user, self.course, "test"))
|
||||
)
|
||||
|
||||
def test_preprocess_collection_escaping(self):
|
||||
"""
|
||||
Tests the result if appropriate module is not found.
|
||||
"""
|
||||
initial_collection = [{
|
||||
u"quote": u"test <script>alert('test')</script>",
|
||||
u"text": u"text \"<>&'",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat()
|
||||
}]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"test <script>alert('test')</script>",
|
||||
u"text": u'text "<>&\'',
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_preprocess_collection_no_item(self):
|
||||
"""
|
||||
Tests the result if appropriate module is not found.
|
||||
"""
|
||||
initial_collection = [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat()
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.course.id.make_usage_key("html", "test_item")),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat()
|
||||
},
|
||||
]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_preprocess_collection_has_access(self):
|
||||
"""
|
||||
Tests the result if the user does not have access to some of the modules.
|
||||
"""
|
||||
initial_collection = [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
},
|
||||
]
|
||||
self.html_module_2.visible_to_staff_only = True
|
||||
self.store.update_item(self.html_module_2, self.user.id)
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.has_access")
|
||||
@patch("edxnotes.helpers.modulestore")
|
||||
def test_preprocess_collection_no_unit(self, mock_modulestore, mock_has_access):
|
||||
"""
|
||||
Tests the result if the unit does not exist.
|
||||
"""
|
||||
store = MagicMock()
|
||||
store.get_parent_location.return_value = None
|
||||
mock_modulestore.return_value = store
|
||||
mock_has_access.return_value = True
|
||||
initial_collection = [{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
}]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[], helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_get_parent_xblock(self):
|
||||
"""
|
||||
Tests `get_parent_xblock` method to return parent xblock or None
|
||||
"""
|
||||
for _ in range(2):
|
||||
# repeat the test twice to make sure caching does not interfere
|
||||
self.assertEqual(helpers.get_parent_xblock(self.html_module_1).location, self.vertical.location)
|
||||
self.assertEqual(helpers.get_parent_xblock(self.sequential).location, self.chapter.location)
|
||||
self.assertEqual(helpers.get_parent_xblock(self.chapter).location, self.course.location)
|
||||
self.assertIsNone(helpers.get_parent_xblock(self.course))
|
||||
|
||||
def test_get_parent_unit(self):
|
||||
"""
|
||||
Tests `get_parent_unit` method for the successful result.
|
||||
"""
|
||||
parent = helpers.get_parent_unit(self.html_module_1)
|
||||
self.assertEqual(parent.location, self.vertical.location)
|
||||
|
||||
parent = helpers.get_parent_unit(self.child_html_module)
|
||||
self.assertEqual(parent.location, self.vertical_with_container.location)
|
||||
|
||||
self.assertIsNone(helpers.get_parent_unit(None))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.course))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.chapter))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.sequential))
|
||||
|
||||
def test_get_module_context_sequential(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the sequential.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.sequential)
|
||||
)
|
||||
|
||||
def test_get_module_context_html_component(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the components.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.html_module_1.display_name_with_default,
|
||||
u"location": unicode(self.html_module_1.location),
|
||||
},
|
||||
helpers.get_module_context(self.course, self.html_module_1)
|
||||
)
|
||||
|
||||
def test_get_module_context_chapter(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the chapters.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.chapter)
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.chapter_2.display_name_with_default,
|
||||
u"index": 1,
|
||||
u"location": unicode(self.chapter_2.location),
|
||||
u"children": [],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.chapter_2)
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"})
|
||||
@patch("edxnotes.helpers.anonymous_id_for_user")
|
||||
@patch("edxnotes.helpers.get_id_token")
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_send_request_with_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user):
|
||||
"""
|
||||
Tests that requests are send with correct information.
|
||||
"""
|
||||
mock_get_id_token.return_value = "test_token"
|
||||
mock_anonymous_id_for_user.return_value = "anonymous_id"
|
||||
helpers.send_request(
|
||||
self.user, self.course.id, path="test", query_string="text"
|
||||
)
|
||||
mock_get.assert_called_with(
|
||||
"http://example.com/test/",
|
||||
headers={
|
||||
"x-annotator-auth-token": "test_token"
|
||||
},
|
||||
params={
|
||||
"user": "anonymous_id",
|
||||
"course_id": unicode(self.course.id),
|
||||
"text": "text",
|
||||
"highlight": True,
|
||||
"highlight_tag": "span",
|
||||
"highlight_class": "note-highlight",
|
||||
}
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"})
|
||||
@patch("edxnotes.helpers.anonymous_id_for_user")
|
||||
@patch("edxnotes.helpers.get_id_token")
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_send_request_without_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user):
|
||||
"""
|
||||
Tests that requests are send with correct information.
|
||||
"""
|
||||
mock_get_id_token.return_value = "test_token"
|
||||
mock_anonymous_id_for_user.return_value = "anonymous_id"
|
||||
helpers.send_request(
|
||||
self.user, self.course.id, path="test"
|
||||
)
|
||||
mock_get.assert_called_with(
|
||||
"http://example.com/test/",
|
||||
headers={
|
||||
"x-annotator-auth-token": "test_token"
|
||||
},
|
||||
params={
|
||||
"user": "anonymous_id",
|
||||
"course_id": unicode(self.course.id),
|
||||
}
|
||||
)
|
||||
|
||||
def test_get_course_position_no_chapter(self):
|
||||
"""
|
||||
Returns `None` if no chapter found.
|
||||
"""
|
||||
mock_course_module = MagicMock()
|
||||
mock_course_module.position = 3
|
||||
mock_course_module.get_display_items.return_value = []
|
||||
self.assertIsNone(helpers.get_course_position(mock_course_module))
|
||||
|
||||
def test_get_course_position_to_chapter(self):
|
||||
"""
|
||||
Returns a position that leads to COURSE/CHAPTER if this isn't the users's
|
||||
first time.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=3)
|
||||
|
||||
mock_chapter = MagicMock()
|
||||
mock_chapter.url_name = 'chapter_url_name'
|
||||
mock_chapter.display_name_with_default = 'Test Chapter Display Name'
|
||||
|
||||
mock_course_module.get_display_items.return_value = [mock_chapter]
|
||||
|
||||
self.assertEqual(helpers.get_course_position(mock_course_module), {
|
||||
'display_name': 'Test Chapter Display Name',
|
||||
'url': '/courses/{}/courseware/chapter_url_name/'.format(self.course.id),
|
||||
})
|
||||
|
||||
def test_get_course_position_no_section(self):
|
||||
"""
|
||||
Returns `None` if no section found.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=None)
|
||||
mock_course_module.get_display_items.return_value = [MagicMock()]
|
||||
self.assertIsNone(helpers.get_course_position(mock_course_module))
|
||||
|
||||
def test_get_course_position_to_section(self):
|
||||
"""
|
||||
Returns a position that leads to COURSE/CHAPTER/SECTION if this is the
|
||||
user's first time.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=None)
|
||||
|
||||
mock_chapter = MagicMock()
|
||||
mock_chapter.url_name = 'chapter_url_name'
|
||||
mock_course_module.get_display_items.return_value = [mock_chapter]
|
||||
|
||||
mock_section = MagicMock()
|
||||
mock_section.url_name = 'section_url_name'
|
||||
mock_section.display_name_with_default = 'Test Section Display Name'
|
||||
|
||||
mock_chapter.get_display_items.return_value = [mock_section]
|
||||
mock_section.get_display_items.return_value = [MagicMock()]
|
||||
|
||||
self.assertEqual(helpers.get_course_position(mock_course_module), {
|
||||
'display_name': 'Test Section Display Name',
|
||||
'url': '/courses/{}/courseware/chapter_url_name/section_url_name/'.format(self.course.id),
|
||||
})
|
||||
|
||||
def test_get_index(self):
|
||||
"""
|
||||
Tests `get_index` method returns unit url.
|
||||
"""
|
||||
children = self.sequential.children
|
||||
self.assertEqual(0, helpers.get_index(unicode(self.vertical.location), children))
|
||||
self.assertEqual(1, helpers.get_index(unicode(self.vertical_with_container.location), children))
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesViewsTest(TestCase):
|
||||
"""
|
||||
Tests for EdxNotes views.
|
||||
"""
|
||||
def setUp(self):
|
||||
ClientFactory(name="edx-notes")
|
||||
super(EdxNotesViewsTest, self).setUp()
|
||||
self.course = CourseFactory.create(edxnotes=True)
|
||||
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
self.notes_page_url = reverse("edxnotes", args=[unicode(self.course.id)])
|
||||
self.search_url = reverse("search_notes", args=[unicode(self.course.id)])
|
||||
self.get_token_url = reverse("get_token", args=[unicode(self.course.id)])
|
||||
self.visibility_url = reverse("edxnotes_visibility", args=[unicode(self.course.id)])
|
||||
|
||||
def _get_course_module(self):
|
||||
"""
|
||||
Returns the course module.
|
||||
"""
|
||||
field_data_cache = FieldDataCache([self.course], self.course.id, self.user)
|
||||
return get_module_for_descriptor(self.user, MagicMock(), self.course, field_data_cache, self.course.id)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.get_notes", return_value=[])
|
||||
def test_edxnotes_view_is_enabled(self, mock_get_notes):
|
||||
"""
|
||||
Tests that appropriate view is received if EdxNotes feature is enabled.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertContains(response, 'Highlights and notes you\'ve made in course content')
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_view_is_disabled(self):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.get_notes")
|
||||
def test_edxnotes_view_404_service_unavailable(self, mock_get_notes):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes service is unavailable.
|
||||
"""
|
||||
mock_get_notes.side_effect = EdxNotesServiceUnavailable
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_successfully_respond(self, mock_search):
|
||||
"""
|
||||
Tests that `search_notes` successfully respond if EdxNotes feature is enabled.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(json.loads(response.content), {
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_is_disabled(self, mock_search):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_404_service_unavailable(self, mock_search):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes service is unavailable.
|
||||
"""
|
||||
mock_search.side_effect = EdxNotesServiceUnavailable
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn("error", response.content)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_without_required_parameters(self, mock_search):
|
||||
"""
|
||||
Tests that 400 status code is received if the required parameters were not sent.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_exception(self, mock_search):
|
||||
"""
|
||||
Tests that 500 status code is received if invalid data was received from
|
||||
EdXNotes service.
|
||||
"""
|
||||
mock_search.side_effect = EdxNotesParseError
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn("error", response.content)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_get_id_token(self):
|
||||
"""
|
||||
Test generation of ID Token.
|
||||
"""
|
||||
response = self.client.get(self.get_token_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
client = Client.objects.get(name='edx-notes')
|
||||
jwt.decode(response.content, client.client_secret)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_get_id_token_anonymous(self):
|
||||
"""
|
||||
Test that generation of ID Token does not work for anonymous user.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.get(self.get_token_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_edxnotes_visibility(self):
|
||||
"""
|
||||
Can update edxnotes_visibility value successfully.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data=json.dumps({"visibility": False}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
course_module = self._get_course_module()
|
||||
self.assertFalse(course_module.edxnotes_visibility)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_visibility_if_feature_is_disabled(self):
|
||||
"""
|
||||
Tests that 404 response is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
response = self.client.post(self.visibility_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_visibility_invalid_json(self):
|
||||
"""
|
||||
Tests that 400 response is received if invalid JSON is sent.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data="string",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_visibility_key_error(self):
|
||||
"""
|
||||
Tests that 400 response is received if invalid data structure is sent.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data=json.dumps({'test_key': 1}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
13
lms/djangoapps/edxnotes/urls.py
Normal file
13
lms/djangoapps/edxnotes/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
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"),
|
||||
)
|
||||
120
lms/djangoapps/edxnotes/views.py
Normal file
120
lms/djangoapps/edxnotes/views.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
AjaxPrefix.addAjaxPrefix(jQuery, -> Courseware.prefix)
|
||||
AjaxPrefix.addAjaxPrefix(jQuery, -> $("meta[name='path_prefix']").attr('content'))
|
||||
|
||||
$ ->
|
||||
$.ajaxSetup
|
||||
|
||||
46
lms/static/js/edxnotes/collections/notes.js
Normal file
46
lms/static/js/edxnotes/collections/notes.js
Normal file
@@ -0,0 +1,46 @@
|
||||
;(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);
|
||||
12
lms/static/js/edxnotes/collections/tabs.js
Normal file
12
lms/static/js/edxnotes/collections/tabs.js
Normal file
@@ -0,0 +1,12 @@
|
||||
;(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);
|
||||
59
lms/static/js/edxnotes/models/note.js
Normal file
59
lms/static/js/edxnotes/models/note.js
Normal file
@@ -0,0 +1,59 @@
|
||||
;(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);
|
||||
34
lms/static/js/edxnotes/models/tab.js
Normal file
34
lms/static/js/edxnotes/models/tab.js
Normal file
@@ -0,0 +1,34 @@
|
||||
;(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);
|
||||
155
lms/static/js/edxnotes/plugins/events.js
Normal file
155
lms/static/js/edxnotes/plugins/events.js
Normal file
@@ -0,0 +1,155 @@
|
||||
;(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);
|
||||
65
lms/static/js/edxnotes/plugins/scroller.js
Normal file
65
lms/static/js/edxnotes/plugins/scroller.js
Normal file
@@ -0,0 +1,65 @@
|
||||
;(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);
|
||||
150
lms/static/js/edxnotes/utils/logger.js
Normal file
150
lms/static/js/edxnotes/utils/logger.js
Normal file
@@ -0,0 +1,150 @@
|
||||
;(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);
|
||||
22
lms/static/js/edxnotes/utils/template.js
Normal file
22
lms/static/js/edxnotes/utils/template.js
Normal file
@@ -0,0 +1,22 @@
|
||||
;(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);
|
||||
70
lms/static/js/edxnotes/views/note_group.js
Normal file
70
lms/static/js/edxnotes/views/note_group.js
Normal file
@@ -0,0 +1,70 @@
|
||||
;(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);
|
||||
71
lms/static/js/edxnotes/views/note_item.js
Normal file
71
lms/static/js/edxnotes/views/note_item.js
Normal file
@@ -0,0 +1,71 @@
|
||||
;(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);
|
||||
94
lms/static/js/edxnotes/views/notes_factory.js
Normal file
94
lms/static/js/edxnotes/views/notes_factory.js
Normal file
@@ -0,0 +1,94 @@
|
||||
;(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);
|
||||
44
lms/static/js/edxnotes/views/notes_page.js
Normal file
44
lms/static/js/edxnotes/views/notes_page.js
Normal file
@@ -0,0 +1,44 @@
|
||||
;(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);
|
||||
25
lms/static/js/edxnotes/views/page_factory.js
Normal file
25
lms/static/js/edxnotes/views/page_factory.js
Normal file
@@ -0,0 +1,25 @@
|
||||
;(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);
|
||||
161
lms/static/js/edxnotes/views/search_box.js
Normal file
161
lms/static/js/edxnotes/views/search_box.js
Normal file
@@ -0,0 +1,161 @@
|
||||
;(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);
|
||||
197
lms/static/js/edxnotes/views/shim.js
Normal file
197
lms/static/js/edxnotes/views/shim.js
Normal file
@@ -0,0 +1,197 @@
|
||||
;(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);
|
||||
57
lms/static/js/edxnotes/views/tab_item.js
Normal file
57
lms/static/js/edxnotes/views/tab_item.js
Normal file
@@ -0,0 +1,57 @@
|
||||
;(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);
|
||||
54
lms/static/js/edxnotes/views/tab_panel.js
Normal file
54
lms/static/js/edxnotes/views/tab_panel.js
Normal file
@@ -0,0 +1,54 @@
|
||||
;(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);
|
||||
138
lms/static/js/edxnotes/views/tab_view.js
Normal file
138
lms/static/js/edxnotes/views/tab_view.js
Normal file
@@ -0,0 +1,138 @@
|
||||
;(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);
|
||||
56
lms/static/js/edxnotes/views/tabs/course_structure.js
Normal file
56
lms/static/js/edxnotes/views/tabs/course_structure.js
Normal file
@@ -0,0 +1,56 @@
|
||||
;(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);
|
||||
31
lms/static/js/edxnotes/views/tabs/recent_activity.js
Normal file
31
lms/static/js/edxnotes/views/tabs/recent_activity.js
Normal file
@@ -0,0 +1,31 @@
|
||||
;(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);
|
||||
148
lms/static/js/edxnotes/views/tabs/search_results.js
Normal file
148
lms/static/js/edxnotes/views/tabs/search_results.js
Normal file
@@ -0,0 +1,148 @@
|
||||
;(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);
|
||||
41
lms/static/js/edxnotes/views/tabs_list.js
Normal file
41
lms/static/js/edxnotes/views/tabs_list.js
Normal file
@@ -0,0 +1,41 @@
|
||||
;(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);
|
||||
98
lms/static/js/edxnotes/views/toggle_notes_factory.js
Normal file
98
lms/static/js/edxnotes/views/toggle_notes_factory.js
Normal file
@@ -0,0 +1,98 @@
|
||||
;(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);
|
||||
74
lms/static/js/edxnotes/views/visibility_decorator.js
Normal file
74
lms/static/js/edxnotes/views/visibility_decorator.js
Normal file
@@ -0,0 +1,74 @@
|
||||
;(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);
|
||||
48
lms/static/js/fixtures/edxnotes/edxnotes.html
Normal file
48
lms/static/js/fixtures/edxnotes/edxnotes.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<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>
|
||||
6
lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html
Normal file
6
lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
7
lms/static/js/fixtures/edxnotes/toggle_notes.html
Normal file
7
lms/static/js/fixtures/edxnotes/toggle_notes.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
34
lms/static/js/spec/edxnotes/collections/notes_spec.js
Normal file
34
lms/static/js/spec/edxnotes/collections/notes_spec.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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)]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
32
lms/static/js/spec/edxnotes/custom_matchers.js
Normal file
32
lms/static/js/spec/edxnotes/custom_matchers.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
169
lms/static/js/spec/edxnotes/helpers.js
Normal file
169
lms/static/js/spec/edxnotes/helpers.js
Normal file
@@ -0,0 +1,169 @@
|
||||
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
|
||||
};
|
||||
});
|
||||
34
lms/static/js/spec/edxnotes/models/note_spec.js
Normal file
34
lms/static/js/spec/edxnotes/models/note_spec.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
33
lms/static/js/spec/edxnotes/models/tab_spec.js
Normal file
33
lms/static/js/spec/edxnotes/models/tab_spec.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
158
lms/static/js/spec/edxnotes/plugins/events_spec.js
Normal file
158
lms/static/js/spec/edxnotes/plugins/events_spec.js
Normal file
@@ -0,0 +1,158 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
94
lms/static/js/spec/edxnotes/plugins/scroller_spec.js
Normal file
94
lms/static/js/spec/edxnotes/plugins/scroller_spec.js
Normal file
@@ -0,0 +1,94 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
lms/static/js/spec/edxnotes/utils/logger_spec.js
Normal file
124
lms/static/js/spec/edxnotes/utils/logger_spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
81
lms/static/js/spec/edxnotes/views/note_item_spec.js
Normal file
81
lms/static/js/spec/edxnotes/views/note_item_spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
lms/static/js/spec/edxnotes/views/notes_factory_spec.js
Normal file
43
lms/static/js/spec/edxnotes/views/notes_factory_spec.js
Normal file
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
lms/static/js/spec/edxnotes/views/notes_page_spec.js
Normal file
46
lms/static/js/spec/edxnotes/views/notes_page_spec.js
Normal file
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
162
lms/static/js/spec/edxnotes/views/search_box_spec.js
Normal file
162
lms/static/js/spec/edxnotes/views/search_box_spec.js
Normal file
@@ -0,0 +1,162 @@
|
||||
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>.',
|
||||
' '
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
125
lms/static/js/spec/edxnotes/views/shim_spec.js
Normal file
125
lms/static/js/spec/edxnotes/views/shim_spec.js
Normal file
@@ -0,0 +1,125 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory', 'jasmine-jquery'
|
||||
], function($, _, Annotator, NotesFactory) {
|
||||
'use strict';
|
||||
describe('EdxNotes Shim', 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();
|
||||
}
|
||||
|
||||
function checkClickEventsNotBound(namespace) {
|
||||
var events = $._data(document, 'events').click;
|
||||
|
||||
_.each(events, function(event) {
|
||||
expect(event.namespace.indexOf(namespace)).toBe(-1);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function() {
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
|
||||
highlights = [];
|
||||
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/'
|
||||
})
|
||||
];
|
||||
_.each(annotators, function(annotator, index) {
|
||||
highlights.push($('<span class="annotator-hl" />').appendTo(annotators[index].element));
|
||||
spyOn(annotator, 'onHighlightClick').andCallThrough();
|
||||
spyOn(annotator, 'onHighlightMouseover').andCallThrough();
|
||||
spyOn(annotator, 'startViewerHideTimer').andCallThrough();
|
||||
});
|
||||
spyOn($.fn, 'off').andCallThrough();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
_.invoke(Annotator._instances, 'destroy');
|
||||
});
|
||||
|
||||
it('clicking a highlight freezes mouseover and mouseout in all highlighted text', function() {
|
||||
_.each(annotators, function(annotator) {
|
||||
expect(annotator.isFrozen).toBe(false);
|
||||
});
|
||||
highlights[0].click();
|
||||
// Click is attached to the onHighlightClick event handler which
|
||||
// in turn calls onHighlightMouseover.
|
||||
// To test if onHighlightMouseover is called or not on
|
||||
// mouseover, we'll have to reset onHighlightMouseover.
|
||||
expect(annotators[0].onHighlightClick).toHaveBeenCalled();
|
||||
expect(annotators[0].onHighlightMouseover).toHaveBeenCalled();
|
||||
annotators[0].onHighlightMouseover.reset();
|
||||
|
||||
// Check that both instances of annotator are frozen
|
||||
_.invoke(highlights, 'mouseover');
|
||||
_.invoke(highlights, 'mouseout');
|
||||
_.each(annotators, checkAnnotatorIsFrozen);
|
||||
});
|
||||
|
||||
it('clicking twice reverts to default behavior', function() {
|
||||
highlights[0].click();
|
||||
$(document).click();
|
||||
annotators[0].onHighlightMouseover.reset();
|
||||
|
||||
// Check that both instances of annotator are unfrozen
|
||||
_.invoke(highlights, 'mouseover');
|
||||
_.invoke(highlights, 'mouseout');
|
||||
_.each(annotators, function(annotator) {
|
||||
checkAnnotatorIsUnfrozen(annotator);
|
||||
});
|
||||
});
|
||||
|
||||
it('destroying an instance with an open viewer sets all other instances' +
|
||||
'to unfrozen and unbinds document click.edxnotes:freeze event handlers', function() {
|
||||
// Freeze all instances
|
||||
highlights[0].click();
|
||||
// Destroy first instance
|
||||
annotators[0].destroy();
|
||||
|
||||
// Check that all click.edxnotes:freeze are unbound
|
||||
checkClickEventsNotBound('edxnotes:freeze');
|
||||
|
||||
// Check that the remaining instance is unfrozen
|
||||
highlights[1].mouseover();
|
||||
highlights[1].mouseout();
|
||||
checkAnnotatorIsUnfrozen(annotators[1]);
|
||||
});
|
||||
|
||||
it('destroying an instance with an closed viewer only unfreezes that instance' +
|
||||
'and unbinds one document click.edxnotes:freeze event handlers', function() {
|
||||
// Freeze all instances
|
||||
highlights[0].click();
|
||||
annotators[0].onHighlightMouseover.reset();
|
||||
// Destroy second instance
|
||||
annotators[1].destroy();
|
||||
|
||||
// Check that the first instance is frozen
|
||||
highlights[0].mouseover();
|
||||
highlights[0].mouseout();
|
||||
checkAnnotatorIsFrozen(annotators[0]);
|
||||
|
||||
// Check that second one doesn't have a bound click.edxnotes:freeze
|
||||
checkClickEventsNotBound('edxnotes:freeze' + annotators[1].uid);
|
||||
});
|
||||
|
||||
it('should unbind onNotesLoaded on destruction', function() {
|
||||
annotators[0].destroy();
|
||||
expect($.fn.off).toHaveBeenCalledWith(
|
||||
'click',
|
||||
annotators[0].onNoteClick
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
lms/static/js/spec/edxnotes/views/tab_item_spec.js
Normal file
54
lms/static/js/spec/edxnotes/views/tab_item_spec.js
Normal file
@@ -0,0 +1,54 @@
|
||||
define([
|
||||
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
|
||||
'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) {
|
||||
'use strict';
|
||||
describe('EdxNotes TabItemView', function() {
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
TemplateHelpers.installTemplate('templates/edxnotes/tab-item');
|
||||
this.collection = new TabsCollection([
|
||||
{identifier: 'first-item'},
|
||||
{
|
||||
identifier: 'second-item',
|
||||
is_closable: true,
|
||||
icon: 'icon-class'
|
||||
}
|
||||
]);
|
||||
this.tabsList = new TabsListView({
|
||||
collection: this.collection
|
||||
}).render();
|
||||
});
|
||||
|
||||
it('can contain an icon', function () {
|
||||
var firstItem = this.tabsList.$('#first-item'),
|
||||
secondItem = this.tabsList.$('#second-item');
|
||||
|
||||
expect(firstItem.find('.icon')).not.toExist();
|
||||
expect(secondItem.find('.icon')).toHaveClass('icon-class');
|
||||
});
|
||||
|
||||
it('can navigate between tabs', function () {
|
||||
var firstItem = this.tabsList.$('#first-item'),
|
||||
secondItem = this.tabsList.$('#second-item');
|
||||
|
||||
expect(firstItem).toHaveClass('is-active'); // first tab is active
|
||||
expect(firstItem).toContainText('Current tab');
|
||||
expect(secondItem).not.toHaveClass('is-active'); // second tab is not active
|
||||
expect(secondItem).not.toContainText('Current tab');
|
||||
secondItem.click();
|
||||
expect(firstItem).not.toHaveClass('is-active'); // first tab is not active
|
||||
expect(firstItem).not.toContainText('Current tab');
|
||||
expect(secondItem).toHaveClass('is-active'); // second tab is active
|
||||
expect(secondItem).toContainText('Current tab');
|
||||
});
|
||||
|
||||
it('can close the tab', function () {
|
||||
var secondItem = this.tabsList.$('#second-item');
|
||||
|
||||
expect(this.tabsList.$('.tab')).toHaveLength(2);
|
||||
secondItem.find('.action-close').click();
|
||||
expect(this.tabsList.$('.tab')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
117
lms/static/js/spec/edxnotes/views/tab_view_spec.js
Normal file
117
lms/static/js/spec/edxnotes/views/tab_view_spec.js
Normal file
@@ -0,0 +1,117 @@
|
||||
define([
|
||||
'jquery', 'backbone', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
|
||||
'js/edxnotes/views/tabs_list', 'js/edxnotes/views/tab_view',
|
||||
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function(
|
||||
$, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView, customMatchers
|
||||
) {
|
||||
'use strict';
|
||||
describe('EdxNotes TabView', function() {
|
||||
var TestSubView = Backbone.View.extend({
|
||||
id: 'test-subview-panel',
|
||||
className: 'tab-panel',
|
||||
content: '<p>test view content</p>',
|
||||
render: function () {
|
||||
this.$el.html(this.content);
|
||||
return this;
|
||||
}
|
||||
}),
|
||||
TestView = TabView.extend({
|
||||
PanelConstructor: TestSubView,
|
||||
tabInfo: {
|
||||
name: 'Test View Tab',
|
||||
is_closable: true
|
||||
}
|
||||
}), getView;
|
||||
|
||||
getView = function (tabsCollection, options) {
|
||||
var view;
|
||||
options = _.defaults(options || {}, {
|
||||
el: $('.wrapper-student-notes'),
|
||||
collection: [],
|
||||
tabsCollection: tabsCollection
|
||||
});
|
||||
view = new TestView(options);
|
||||
|
||||
if (tabsCollection.length) {
|
||||
tabsCollection.at(0).activate();
|
||||
}
|
||||
|
||||
return view;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
|
||||
TemplateHelpers.installTemplates([
|
||||
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
|
||||
]);
|
||||
this.tabsCollection = new TabsCollection();
|
||||
this.tabsList = new TabsListView({collection: this.tabsCollection}).render();
|
||||
this.tabsList.$el.appendTo($('.tab-list'));
|
||||
});
|
||||
|
||||
it('can create a tab and content on initialization', function () {
|
||||
var view = getView(this.tabsCollection);
|
||||
expect(this.tabsCollection).toHaveLength(1);
|
||||
expect(view.$('.tab')).toExist();
|
||||
expect(view.$('.wrapper-tabs')).toContainHtml('<p>test view content</p>');
|
||||
});
|
||||
|
||||
it('cannot create a tab on initialization if flag is not set', function () {
|
||||
var view = getView(this.tabsCollection, {
|
||||
createTabOnInitialization: false
|
||||
});
|
||||
expect(this.tabsCollection).toHaveLength(0);
|
||||
expect(view.$('.tab')).not.toExist();
|
||||
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
|
||||
});
|
||||
|
||||
it('can remove the content if tab becomes inactive', function () {
|
||||
var view = getView(this.tabsCollection);
|
||||
this.tabsCollection.add({identifier: 'second-tab'});
|
||||
view.$('#second-tab').click();
|
||||
expect(view.$('.tab')).toHaveLength(2);
|
||||
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
|
||||
});
|
||||
|
||||
it('can remove the content if tab is closed', function () {
|
||||
var view = getView(this.tabsCollection);
|
||||
view.onClose = jasmine.createSpy();
|
||||
view.$('.tab .action-close').click();
|
||||
expect(view.$('.tab')).toHaveLength(0);
|
||||
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
|
||||
expect(view.tabModel).toBeNull();
|
||||
expect(view.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can correctly update the content of active tab', function () {
|
||||
var view = getView(this.tabsCollection);
|
||||
TestSubView.prototype.content = '<p>New content</p>';
|
||||
view.render();
|
||||
expect(view.$('.wrapper-tabs')).toContainHtml('<p>New content</p>');
|
||||
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
|
||||
});
|
||||
|
||||
it('can show/hide error messages', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
errorHolder = view.$('.wrapper-msg');
|
||||
view.showErrorMessage('<p>error message is here</p>');
|
||||
expect(errorHolder).not.toHaveClass('is-hidden');
|
||||
expect(errorHolder.find('.copy')).toContainHtml('<p>error message is here</p>');
|
||||
|
||||
view.hideErrorMessage();
|
||||
expect(errorHolder).toHaveClass('is-hidden');
|
||||
expect(errorHolder.find('.copy')).toBeEmpty();
|
||||
});
|
||||
|
||||
it('should hide error messages before rendering', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
errorHolder = view.$('.wrapper-msg');
|
||||
view.showErrorMessage('<p>error message is here</p>');
|
||||
view.render();
|
||||
expect(errorHolder).toHaveClass('is-hidden');
|
||||
expect(errorHolder.find('.copy')).toBeEmpty();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers',
|
||||
'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs',
|
||||
'js/edxnotes/views/tabs/course_structure', 'js/spec/edxnotes/custom_matchers',
|
||||
'jasmine-jquery'
|
||||
], function(
|
||||
$, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, CourseStructureView,
|
||||
customMatchers
|
||||
) {
|
||||
'use strict';
|
||||
describe('EdxNotes CourseStructureView', function() {
|
||||
var notes = Helpers.getDefaultNotes(),
|
||||
getView, getText;
|
||||
|
||||
getText = function (selector) {
|
||||
return $(selector).map(function () {
|
||||
return _.trim($(this).text());
|
||||
}).toArray();
|
||||
};
|
||||
|
||||
getView = function (collection, tabsCollection, options) {
|
||||
var view;
|
||||
|
||||
options = _.defaults(options || {}, {
|
||||
el: $('.wrapper-student-notes'),
|
||||
collection: collection,
|
||||
tabsCollection: tabsCollection,
|
||||
});
|
||||
|
||||
view = new CourseStructureView(options);
|
||||
tabsCollection.at(0).activate();
|
||||
|
||||
return view;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
|
||||
TemplateHelpers.installTemplates([
|
||||
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
|
||||
]);
|
||||
|
||||
this.collection = new NotesCollection(notes);
|
||||
this.tabsCollection = new TabsCollection();
|
||||
});
|
||||
|
||||
it('displays a tab and content with proper data and order', function () {
|
||||
var view = getView(this.collection, this.tabsCollection),
|
||||
chapters = getText('.course-title'),
|
||||
sections = getText('.course-subtitle'),
|
||||
notes = getText('.note-excerpt-p');
|
||||
|
||||
expect(this.tabsCollection).toHaveLength(1);
|
||||
expect(this.tabsCollection.at(0).toJSON()).toEqual({
|
||||
name: 'Location in Course',
|
||||
identifier: 'view-course-structure',
|
||||
icon: 'fa fa-list-ul',
|
||||
is_active: true,
|
||||
is_closable: false
|
||||
});
|
||||
expect(view.$('#structure-panel')).toExist();
|
||||
expect(chapters).toEqual(['First Chapter', 'Second Chapter']);
|
||||
expect(sections).toEqual(['First Section', 'Second Section', 'Third Section']);
|
||||
expect(notes).toEqual(['Note 1', 'Note 2', 'Note 3', 'Note 4', 'Note 5']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
define([
|
||||
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/notes',
|
||||
'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/recent_activity',
|
||||
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function(
|
||||
$, TemplateHelpers, NotesCollection, TabsCollection, RecentActivityView, customMatchers
|
||||
) {
|
||||
'use strict';
|
||||
describe('EdxNotes RecentActivityView', function() {
|
||||
var notes = [
|
||||
{
|
||||
created: 'December 11, 2014 at 11:12AM',
|
||||
updated: 'December 11, 2014 at 11:12AM',
|
||||
text: 'Third added model',
|
||||
quote: 'Should be listed first'
|
||||
},
|
||||
{
|
||||
created: 'December 11, 2014 at 11:11AM',
|
||||
updated: 'December 11, 2014 at 11:11AM',
|
||||
text: 'Second added model',
|
||||
quote: 'Should be listed second'
|
||||
},
|
||||
{
|
||||
created: 'December 11, 2014 at 11:10AM',
|
||||
updated: 'December 11, 2014 at 11:10AM',
|
||||
text: 'First added model',
|
||||
quote: 'Should be listed third'
|
||||
}
|
||||
], getView;
|
||||
|
||||
getView = function (collection, tabsCollection, options) {
|
||||
var view;
|
||||
|
||||
options = _.defaults(options || {}, {
|
||||
el: $('.wrapper-student-notes'),
|
||||
collection: collection,
|
||||
tabsCollection: tabsCollection,
|
||||
});
|
||||
|
||||
view = new RecentActivityView(options);
|
||||
tabsCollection.at(0).activate();
|
||||
|
||||
return view;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
|
||||
TemplateHelpers.installTemplates([
|
||||
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
|
||||
]);
|
||||
|
||||
this.collection = new NotesCollection(notes);
|
||||
this.tabsCollection = new TabsCollection();
|
||||
});
|
||||
|
||||
it('displays a tab and content with proper data and order', function () {
|
||||
var view = getView(this.collection, this.tabsCollection);
|
||||
|
||||
expect(this.tabsCollection).toHaveLength(1);
|
||||
expect(this.tabsCollection.at(0).toJSON()).toEqual({
|
||||
name: 'Recent Activity',
|
||||
identifier: 'view-recent-activity',
|
||||
icon: 'fa fa-clock-o',
|
||||
is_active: true,
|
||||
is_closable: false
|
||||
});
|
||||
expect(view.$('#recent-panel')).toExist();
|
||||
expect(view.$('.note')).toHaveLength(3);
|
||||
_.each(view.$('.note'), function(element, index) {
|
||||
expect($('.note-comments', element)).toContainText(notes[index].text);
|
||||
expect($('.note-excerpt', element)).toContainText(notes[index].quote);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
205
lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js
Normal file
205
lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js
Normal file
@@ -0,0 +1,205 @@
|
||||
define([
|
||||
'jquery', 'js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers',
|
||||
'logger', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results',
|
||||
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function(
|
||||
$, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView,
|
||||
customMatchers
|
||||
) {
|
||||
'use strict';
|
||||
describe('EdxNotes SearchResultsView', function() {
|
||||
var notes = [
|
||||
{
|
||||
created: 'December 11, 2014 at 11:12AM',
|
||||
updated: 'December 11, 2014 at 11:12AM',
|
||||
text: 'Third added model',
|
||||
quote: 'Should be listed first'
|
||||
},
|
||||
{
|
||||
created: 'December 11, 2014 at 11:11AM',
|
||||
updated: 'December 11, 2014 at 11:11AM',
|
||||
text: 'Second added model',
|
||||
quote: 'Should be listed second'
|
||||
},
|
||||
{
|
||||
created: 'December 11, 2014 at 11:10AM',
|
||||
updated: 'December 11, 2014 at 11:10AM',
|
||||
text: 'First added model',
|
||||
quote: 'Should be listed third'
|
||||
}
|
||||
],
|
||||
responseJson = {
|
||||
total: 3,
|
||||
rows: notes
|
||||
},
|
||||
getView, submitForm;
|
||||
|
||||
getView = function (tabsCollection, options) {
|
||||
options = _.defaults(options || {}, {
|
||||
el: $('.wrapper-student-notes'),
|
||||
tabsCollection: tabsCollection,
|
||||
user: 'test_user',
|
||||
courseId: 'course_id',
|
||||
createTabOnInitialization: false
|
||||
});
|
||||
return new SearchResultsView(options);
|
||||
};
|
||||
|
||||
submitForm = function (searchBox, text) {
|
||||
searchBox.$('.search-notes-input').val(text);
|
||||
searchBox.$('.search-notes-submit').click();
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
|
||||
TemplateHelpers.installTemplates([
|
||||
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
|
||||
]);
|
||||
|
||||
this.tabsCollection = new TabsCollection();
|
||||
});
|
||||
|
||||
it('does not create a tab and content on initialization', function () {
|
||||
var view = getView(this.tabsCollection);
|
||||
expect(this.tabsCollection).toHaveLength(0);
|
||||
expect(view.$('#search-results-panel')).not.toExist();
|
||||
});
|
||||
|
||||
it('displays a tab and content on search with proper data and order', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
submitForm(view.searchBox, 'second');
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
|
||||
expect(this.tabsCollection).toHaveLength(1);
|
||||
expect(this.tabsCollection.at(0).toJSON()).toEqual({
|
||||
name: 'Search Results',
|
||||
identifier: 'view-search-results',
|
||||
icon: 'fa fa-search',
|
||||
is_active: true,
|
||||
is_closable: true
|
||||
});
|
||||
expect(view.$('#search-results-panel')).toExist();
|
||||
expect(view.$('#search-results-panel')).toBeFocused();
|
||||
expect(view.$('.note')).toHaveLength(3);
|
||||
view.searchResults.collection.each(function (model, index) {
|
||||
expect(model.get('text')).toBe(notes[index].text);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays loading indicator when search is running', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
submitForm(view.searchBox, 'test query');
|
||||
expect(view.$('.ui-loading')).not.toHaveClass('is-hidden');
|
||||
expect(view.$('.ui-loading')).toBeFocused();
|
||||
expect(this.tabsCollection).toHaveLength(1);
|
||||
expect(view.searchResults).toBeNull();
|
||||
expect(view.$('.tab-panel')).not.toExist();
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('displays no results message', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
submitForm(view.searchBox, 'some text');
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
total: 0,
|
||||
rows: []
|
||||
});
|
||||
|
||||
expect(view.$('#search-results-panel')).not.toExist();
|
||||
expect(view.$('#no-results-panel')).toBeFocused();
|
||||
expect(view.$('#no-results-panel')).toExist();
|
||||
expect(view.$('#no-results-panel')).toContainText(
|
||||
'No results found for "some text".'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send an additional request on switching between tabs', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
spyOn(Logger, 'log');
|
||||
submitForm(view.searchBox, 'test_query');
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
this.tabsCollection.add({});
|
||||
this.tabsCollection.at(1).activate();
|
||||
expect(view.$('#search-results-panel')).not.toExist();
|
||||
this.tabsCollection.at(0).activate();
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(view.$('#search-results-panel')).toExist();
|
||||
expect(view.$('.note')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('can clear search results if tab is closed', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
submitForm(view.searchBox, 'test_query');
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
expect(view.searchResults).toBeDefined();
|
||||
this.tabsCollection.at(0).destroy();
|
||||
expect(view.searchResults).toBeNull();
|
||||
});
|
||||
|
||||
it('can correctly show/hide error messages', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
submitForm(view.searchBox, 'test error');
|
||||
requests[0].respond(
|
||||
500, {'Content-Type': 'application/json'},
|
||||
JSON.stringify({
|
||||
error: 'test error message'
|
||||
})
|
||||
);
|
||||
|
||||
expect(view.$('.wrapper-msg')).not.toHaveClass('is-hidden');
|
||||
expect(view.$('.wrapper-msg .copy')).toContainText('test error message');
|
||||
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
|
||||
submitForm(view.searchBox, 'Second');
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
|
||||
expect(view.$('.wrapper-msg')).toHaveClass('is-hidden');
|
||||
expect(view.$('.wrapper-msg .copy')).toBeEmpty();
|
||||
});
|
||||
|
||||
it('can correctly update search results', function () {
|
||||
var view = getView(this.tabsCollection),
|
||||
requests = AjaxHelpers.requests(this),
|
||||
newNotes = [{
|
||||
created: 'December 11, 2014 at 11:10AM',
|
||||
updated: 'December 11, 2014 at 11:10AM',
|
||||
text: 'New Note',
|
||||
quote: 'New Note'
|
||||
}];
|
||||
|
||||
submitForm(view.searchBox, 'test_query');
|
||||
AjaxHelpers.respondWithJson(requests, responseJson);
|
||||
|
||||
expect(view.$('.note')).toHaveLength(3);
|
||||
|
||||
submitForm(view.searchBox, 'new_test_query');
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
total: 1,
|
||||
rows: newNotes
|
||||
});
|
||||
|
||||
expect(view.$('.note').length).toHaveLength(1);
|
||||
view.searchResults.collection.each(function (model, index) {
|
||||
expect(model.get('text')).toBe(newNotes[index].text);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
lms/static/js/spec/edxnotes/views/tabs_list_spec.js
Normal file
50
lms/static/js/spec/edxnotes/views/tabs_list_spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
define([
|
||||
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
|
||||
'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) {
|
||||
'use strict';
|
||||
describe('EdxNotes TabsListView', function() {
|
||||
beforeEach(function () {
|
||||
customMatchers(this);
|
||||
TemplateHelpers.installTemplate('templates/edxnotes/tab-item');
|
||||
this.collection = new TabsCollection([
|
||||
{identifier: 'first-item'},
|
||||
{identifier: 'second-item'}
|
||||
]);
|
||||
this.tabsList = new TabsListView({
|
||||
collection: this.collection
|
||||
}).render();
|
||||
});
|
||||
|
||||
it('has correct order and class names', function () {
|
||||
var firstItem = this.tabsList.$('#first-item'),
|
||||
secondItem = this.tabsList.$('#second-item');
|
||||
|
||||
expect(firstItem).toHaveIndex(0);
|
||||
expect(firstItem).toHaveClass('is-active');
|
||||
expect(secondItem).toHaveIndex(1);
|
||||
});
|
||||
|
||||
it('can add a new tab', function () {
|
||||
var firstItem = this.tabsList.$('#first-item'),
|
||||
thirdItem;
|
||||
|
||||
this.collection.add({identifier: 'third-item'});
|
||||
thirdItem = this.tabsList.$('#third-item');
|
||||
|
||||
expect(firstItem).toHaveClass('is-active'); // first tab is still active
|
||||
expect(thirdItem).toHaveIndex(2);
|
||||
expect(this.tabsList.$('.tab')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('can remove tabs', function () {
|
||||
var secondItem = this.tabsList.$('#second-item');
|
||||
|
||||
this.collection.at(0).destroy(); // remove first tab
|
||||
expect(this.tabsList.$('.tab')).toHaveLength(1);
|
||||
expect(secondItem).toHaveClass('is-active'); // second tab becomes active
|
||||
this.collection.at(0).destroy();
|
||||
expect(this.tabsList.$('.tab')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
define([
|
||||
'jquery', 'annotator', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/visibility_decorator',
|
||||
'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/helpers',
|
||||
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
|
||||
], function(
|
||||
$, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, Helpers,
|
||||
customMatchers
|
||||
) {
|
||||
'use strict';
|
||||
describe('EdxNotes ToggleNotesFactory', function() {
|
||||
var params = {
|
||||
endpoint: '/test_endpoint',
|
||||
user: 'a user',
|
||||
usageId : 'an usage',
|
||||
courseId: 'a course',
|
||||
token: Helpers.makeToken(),
|
||||
tokenUrl: '/test_token_url'
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
customMatchers(this);
|
||||
loadFixtures(
|
||||
'js/fixtures/edxnotes/edxnotes_wrapper.html',
|
||||
'js/fixtures/edxnotes/toggle_notes.html'
|
||||
);
|
||||
VisibilityDecorator.factory(
|
||||
document.getElementById('edx-notes-wrapper-123'), params, true
|
||||
);
|
||||
VisibilityDecorator.factory(
|
||||
document.getElementById('edx-notes-wrapper-456'), params, true
|
||||
);
|
||||
this.toggleNotes = ToggleNotesFactory(true, '/test_url');
|
||||
this.button = $('.action-toggle-notes');
|
||||
this.label = this.button.find('.utility-control-label');
|
||||
this.toggleMessage = $('.action-toggle-message');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
VisibilityDecorator._setVisibility(null);
|
||||
_.invoke(Annotator._instances, 'destroy');
|
||||
$('.annotator-notice').remove();
|
||||
});
|
||||
|
||||
it('can toggle notes', function() {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
|
||||
expect(this.button).not.toHaveClass('is-disabled');
|
||||
expect(this.label).toContainText('Hide notes');
|
||||
expect(this.button).toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'true');
|
||||
expect(this.toggleMessage).not.toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Hiding notes');
|
||||
|
||||
this.button.click();
|
||||
expect(this.label).toContainText('Show notes');
|
||||
expect(this.button).not.toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'false');
|
||||
expect(this.toggleMessage).toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Hiding notes');
|
||||
expect(Annotator._instances).toHaveLength(0);
|
||||
|
||||
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
|
||||
'visibility': false
|
||||
});
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
|
||||
this.button.click();
|
||||
expect(this.label).toContainText('Hide notes');
|
||||
expect(this.button).toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'true');
|
||||
expect(this.toggleMessage).toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Showing notes');
|
||||
expect(Annotator._instances).toHaveLength(2);
|
||||
|
||||
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
|
||||
'visibility': true
|
||||
});
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
});
|
||||
|
||||
it('can handle errors', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
errorContainer = $('.annotator-notice');
|
||||
|
||||
this.button.click();
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
expect(errorContainer).toContainText(
|
||||
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."
|
||||
);
|
||||
expect(errorContainer).toBeVisible();
|
||||
expect(errorContainer).toHaveClass('annotator-notice-show');
|
||||
expect(errorContainer).toHaveClass('annotator-notice-error');
|
||||
|
||||
this.button.click();
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
expect(errorContainer).not.toHaveClass('annotator-notice-show');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
define([
|
||||
'annotator', 'js/edxnotes/views/visibility_decorator',
|
||||
'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers'
|
||||
], function(Annotator, VisibilityDecorator, Helpers, customMatchers) {
|
||||
'use strict';
|
||||
describe('EdxNotes VisibilityDecorator', function() {
|
||||
var params = {
|
||||
endpoint: '/test_endpoint',
|
||||
user: 'a user',
|
||||
usageId : 'an usage',
|
||||
courseId: 'a course',
|
||||
token: Helpers.makeToken(),
|
||||
tokenUrl: '/test_token_url'
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
|
||||
this.wrapper = document.getElementById('edx-notes-wrapper-123');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
VisibilityDecorator._setVisibility(null);
|
||||
_.invoke(Annotator._instances, 'destroy');
|
||||
});
|
||||
|
||||
it('can initialize Notes if it visibility equals True', function() {
|
||||
var note = VisibilityDecorator.factory(this.wrapper, params, true);
|
||||
expect(note).toEqual(jasmine.any(Annotator));
|
||||
});
|
||||
|
||||
it('does not initialize Notes if it visibility equals False', function() {
|
||||
var note = VisibilityDecorator.factory(this.wrapper, params, false);
|
||||
expect(note).toBeNull();
|
||||
});
|
||||
|
||||
it('can disable all notes', function() {
|
||||
VisibilityDecorator.factory(this.wrapper, params, true);
|
||||
VisibilityDecorator.factory(document.getElementById('edx-notes-wrapper-456'), params, true);
|
||||
|
||||
VisibilityDecorator.disableNotes();
|
||||
expect(Annotator._instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can enable the note', function() {
|
||||
var secondWrapper = document.getElementById('edx-notes-wrapper-456');
|
||||
VisibilityDecorator.factory(this.wrapper, params, false);
|
||||
VisibilityDecorator.factory(secondWrapper, params, false);
|
||||
|
||||
VisibilityDecorator.enableNote(this.wrapper);
|
||||
expect(Annotator._instances).toHaveLength(1);
|
||||
VisibilityDecorator.enableNote(secondWrapper);
|
||||
expect(Annotator._instances).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
(function(requirejs, define) {
|
||||
|
||||
// TODO: how can we share the vast majority of this config that is in common with CMS?
|
||||
requirejs.config({
|
||||
paths: {
|
||||
@@ -54,6 +53,7 @@
|
||||
'xblock/lms.runtime.v1': 'coffee/src/xblock/lms.runtime.v1',
|
||||
'capa/display': 'xmodule_js/src/capa/display',
|
||||
'string_utils': 'xmodule_js/common_static/js/src/string_utils',
|
||||
'logger': 'xmodule_js/common_static/js/src/logger',
|
||||
|
||||
// Manually specify LMS files that are not converted to RequireJS
|
||||
'history': 'js/vendor/history',
|
||||
@@ -77,7 +77,10 @@
|
||||
'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel',
|
||||
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
|
||||
'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
|
||||
'js/student_profile/profile': 'js/student_profile/profile'
|
||||
'js/student_profile/profile': 'js/student_profile/profile',
|
||||
|
||||
// edxnotes
|
||||
'annotator': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min'
|
||||
},
|
||||
shim: {
|
||||
'gettext': {
|
||||
@@ -211,6 +214,9 @@
|
||||
'xmodule': {
|
||||
exports: 'XModule'
|
||||
},
|
||||
'logger': {
|
||||
exports: 'Logger'
|
||||
},
|
||||
'sinon': {
|
||||
exports: 'sinon'
|
||||
},
|
||||
@@ -488,6 +494,11 @@
|
||||
'js/verify_student/views/enrollment_confirmation_step_view'
|
||||
]
|
||||
},
|
||||
// Student Notes
|
||||
'annotator': {
|
||||
exports: 'Annotator',
|
||||
deps: ['jquery']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -514,7 +525,26 @@
|
||||
'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js',
|
||||
'lms/include/js/spec/verify_student/webcam_photo_view_spec.js',
|
||||
'lms/include/js/spec/verify_student/review_photos_step_view_spec.js',
|
||||
'lms/include/js/spec/verify_student/make_payment_step_view_spec.js'
|
||||
'lms/include/js/spec/verify_student/make_payment_step_view_spec.js',
|
||||
'lms/include/js/spec/edxnotes/utils/logger_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/notes_factory_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/shim_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/note_item_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/notes_page_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/search_box_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tabs_list_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tab_item_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tab_view_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tabs/search_results_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tabs/recent_activity_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/tabs/course_structure_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/visibility_decorator_spec.js',
|
||||
'lms/include/js/spec/edxnotes/views/toggle_notes_factory_spec.js',
|
||||
'lms/include/js/spec/edxnotes/models/tab_spec.js',
|
||||
'lms/include/js/spec/edxnotes/models/note_spec.js',
|
||||
'lms/include/js/spec/edxnotes/plugins/events_spec.js',
|
||||
'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
|
||||
'lms/include/js/spec/edxnotes/collections/notes_spec.js'
|
||||
]);
|
||||
|
||||
}).call(this, requirejs, define);
|
||||
|
||||
@@ -30,7 +30,7 @@ prepend_path: lms/static
|
||||
lib_paths:
|
||||
- xmodule_js/common_static/js/test/i18n.js
|
||||
- xmodule_js/common_static/coffee/src/ajax_prefix.js
|
||||
- xmodule_js/common_static/coffee/src/logger.js
|
||||
- xmodule_js/common_static/js/src/logger.js
|
||||
- xmodule_js/common_static/js/vendor/jasmine-jquery.js
|
||||
- xmodule_js/common_static/js/vendor/jasmine-imagediff.js
|
||||
- xmodule_js/common_static/js/vendor/require.js
|
||||
@@ -55,6 +55,9 @@ lib_paths:
|
||||
- xmodule_js/common_static/js/vendor/underscore-min.js
|
||||
- xmodule_js/common_static/js/vendor/underscore.string.min.js
|
||||
- xmodule_js/common_static/js/vendor/backbone-min.js
|
||||
- xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js
|
||||
- xmodule_js/common_static/js/test/i18n.js
|
||||
- xmodule_js/common_static/js/vendor/date.js
|
||||
|
||||
# Paths to source JavaScript files
|
||||
src_paths:
|
||||
@@ -77,10 +80,12 @@ spec_paths:
|
||||
fixture_paths:
|
||||
- templates/instructor/instructor_dashboard_2
|
||||
- templates/dashboard
|
||||
- templates/edxnotes
|
||||
- templates/student_account
|
||||
- templates/student_profile
|
||||
- templates/verify_student
|
||||
- templates/file-upload.underscore
|
||||
- js/fixtures/edxnotes
|
||||
|
||||
requirejs:
|
||||
paths:
|
||||
|
||||
@@ -30,7 +30,7 @@ prepend_path: lms/static
|
||||
lib_paths:
|
||||
- xmodule_js/common_static/js/test/i18n.js
|
||||
- xmodule_js/common_static/coffee/src/ajax_prefix.js
|
||||
- xmodule_js/common_static/coffee/src/logger.js
|
||||
- xmodule_js/common_static/js/src/logger.js
|
||||
- xmodule_js/common_static/js/vendor/jasmine-jquery.js
|
||||
- xmodule_js/common_static/js/vendor/jasmine-imagediff.js
|
||||
- xmodule_js/common_static/js/vendor/require.js
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
;(function (require, define, _) {
|
||||
;(function (require, define) {
|
||||
var paths = {}, config;
|
||||
|
||||
// URI, tinymce, or jquery.tinymce may already have been loaded before the OVA templates and we do not want to load
|
||||
// them a second time. Check if it is the case and use the global var in requireJS config.
|
||||
// jquery, underscore, gettext, URI, tinymce, or jquery.tinymce may already
|
||||
// have been loaded and we do not want to load them a second time. Check if
|
||||
// it is the case and use the global var instead.
|
||||
if (window.jQuery) {
|
||||
define("jquery", [], function() {return window.jQuery;});
|
||||
} else {
|
||||
paths.jquery = "js/vendor/jquery.min";
|
||||
}
|
||||
if (window._) {
|
||||
define("underscore", [], function() {return window._;});
|
||||
} else {
|
||||
paths.jquery = "js/vendor/underscore-min";
|
||||
}
|
||||
if (window.gettext) {
|
||||
define("gettext", [], function() {return window.gettext;});
|
||||
} else {
|
||||
paths.gettext = "/i18n";
|
||||
}
|
||||
if (window.Logger) {
|
||||
define("logger", [], function() {return window.Logger;});
|
||||
} else {
|
||||
paths.logger = "js/src/logger";
|
||||
}
|
||||
if (window.URI) {
|
||||
define("URI", [], function() {return window.URI;});
|
||||
} else {
|
||||
@@ -20,10 +41,14 @@
|
||||
}
|
||||
|
||||
config = {
|
||||
// NOTE: baseUrl has been previously set in lms/templates/main.html
|
||||
// NOTE: baseUrl has been previously set in lms/static/templates/main.html
|
||||
waitSeconds: 60,
|
||||
paths: {
|
||||
// Files only needed for OVA
|
||||
"annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min",
|
||||
"date": "js/vendor/date",
|
||||
"backbone": "js/vendor/backbone-min",
|
||||
"underscore.string": "js/vendor/underscore.string.min",
|
||||
// Files needed by OVA
|
||||
"annotator": "js/vendor/ova/annotator-full",
|
||||
"annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth",
|
||||
"video.dev": "js/vendor/ova/video.dev",
|
||||
@@ -42,10 +67,30 @@
|
||||
"ova": 'js/vendor/ova/ova',
|
||||
"catch": 'js/vendor/ova/catch/js/catch',
|
||||
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
|
||||
// end of files only needed for OVA
|
||||
// end of files needed by OVA
|
||||
},
|
||||
shim: {
|
||||
// The following are all needed for OVA
|
||||
"annotator_1.2.9": {
|
||||
deps: ["jquery"],
|
||||
exports: "Annotator"
|
||||
},
|
||||
"date": {
|
||||
exports: "Date"
|
||||
},
|
||||
"jquery": {
|
||||
exports: "$"
|
||||
},
|
||||
"underscore": {
|
||||
exports: "_"
|
||||
},
|
||||
"backbone": {
|
||||
deps: ["underscore", "jquery"],
|
||||
exports: "Backbone"
|
||||
},
|
||||
"logger": {
|
||||
exports: "Logger"
|
||||
},
|
||||
// Needed by OVA
|
||||
"video.dev": {
|
||||
exports:"videojs"
|
||||
},
|
||||
@@ -74,7 +119,7 @@
|
||||
deps: ["annotator"]
|
||||
},
|
||||
"diacritic-annotator": {
|
||||
deps: ["annotator"]
|
||||
deps: ["annotator"]
|
||||
},
|
||||
"flagging-annotator": {
|
||||
deps: ["annotator"]
|
||||
@@ -99,9 +144,19 @@
|
||||
"URI"
|
||||
]
|
||||
},
|
||||
// End of OVA
|
||||
// End of needed by OVA
|
||||
},
|
||||
map: {
|
||||
"js/edxnotes/*": {
|
||||
"annotator": "annotator_1.2.9"
|
||||
}
|
||||
}
|
||||
};
|
||||
_.extend(config.paths, paths);
|
||||
|
||||
for (var key in paths) {
|
||||
if ({}.hasOwnProperty.call(paths, key)) {
|
||||
config.paths[key] = paths[key];
|
||||
}
|
||||
}
|
||||
require.config(config);
|
||||
}).call(this, require || RequireJS.require, define || RequireJS.define, _);
|
||||
}).call(this, require || RequireJS.require, define || RequireJS.define);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user