Commit c7153be0 by polesye Committed by Tim Babych

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

Co-Authored-By: Jean-Michel Claus <jmc@edx.org>
Co-Authored-By: Brian Talbot <btalbot@edx.org>
Co-Authored-By: Tim Babych <tim@edx.org>
Co-Authored-By: Oleg Marshev <oleg@edx.org>
Co-Authored-By: Chris Rodriguez <crodriguez@edx.org>
parent c11a9f05
......@@ -5,6 +5,28 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
LMS: Student Notes: Eventing for Student Notes. TNL-931
LMS: Student Notes: Add course structure view. TNL-762
LMS: Student Notes: Scroll and opening of notes. TNL-784
LMS: Student Notes: Add styling to Notes page. TNL-932
LMS: Student Notes: Add more graceful error message.
LMS: Student Notes: Toggle all notes TNL-661
LMS: Student Notes: Use JWT ID-Token for authentication annotation requests. TNL-782
LMS: Student Notes: Add possibility to search notes. TNL-731
LMS: Student Notes: Toggle single note visibility. TNL-660
LMS: Student Notes: Add Notes page. TNL-797
LMS: Student Notes: Add possibility to add/edit/remove notes. TNL-655
Platform: Add group_access field to all xblocks. TNL-670
LMS: Add support for user partitioning based on cohort. TNL-710
......
......@@ -552,6 +552,80 @@ class CourseMetadataEditingTest(CourseTestCase):
)
self.assertNotIn('giturl', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_edxnotes_present(self):
"""
If feature flag ENABLE_EDXNOTES is on, show the setting as a non-deprecated Advanced Setting.
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_edxnotes_not_present(self):
"""
If feature flag ENABLE_EDXNOTES is off, don't show the setting at all on the Advanced Settings page.
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertNotIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_validate_update_filtered_edxnotes_off(self):
"""
If feature flag is off, then edxnotes must be filtered.
"""
# pylint: disable=unused-variable
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertNotIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_validate_update_filtered_edxnotes_on(self):
"""
If feature flag is on, then edxnotes must not be filtered.
"""
# pylint: disable=unused-variable
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_update_from_json_filtered_edxnotes_on(self):
"""
If feature flag is on, then edxnotes must be updated.
"""
test_model = CourseMetadata.update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertIn('edxnotes', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
def test_update_from_json_filtered_edxnotes_off(self):
"""
If feature flag is off, then edxnotes must not be updated.
"""
test_model = CourseMetadata.update_from_json(
self.course,
{
"edxnotes": {"value": "true"},
},
user=self.user
)
self.assertNotIn('edxnotes', test_model)
def test_validate_and_update_from_json_correct_inputs(self):
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
......@@ -711,6 +785,23 @@ class CourseMetadataEditingTest(CourseTestCase):
course = modulestore().get_course(self.course.id)
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
def test_course_settings_munge_tabs(self):
"""
Test that adding and removing specific course settings adds and removes tabs.
"""
self.assertNotIn(EXTRA_TAB_PANELS.get("edxnotes"), self.course.tabs)
self.client.ajax_post(self.course_setting_url, {
"edxnotes": {"value": True}
})
course = modulestore().get_course(self.course.id)
self.assertIn(EXTRA_TAB_PANELS.get("edxnotes"), course.tabs)
self.client.ajax_post(self.course_setting_url, {
"edxnotes": {"value": False}
})
course = modulestore().get_course(self.course.id)
self.assertNotIn(EXTRA_TAB_PANELS.get("edxnotes"), course.tabs)
class CourseGraderUpdatesTest(CourseTestCase):
"""
......
......@@ -30,7 +30,8 @@ log = logging.getLogger(__name__)
# In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name": _("Open Ended Panel"), "type": "open_ended"}
NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
EDXNOTES_PANEL = {"name": _("Notes"), "type": "edxnotes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL, EDXNOTES_PANEL]])
def add_instructor(course_key, requesting_user, new_instructor):
......
......@@ -867,62 +867,100 @@ def grading_handler(request, course_key_string, grader_index=None):
# pylint: disable=invalid-name
def _config_course_advanced_components(request, course_module):
"""
Check to see if the user instantiated any advanced components. This
is a hack that does the following :
1) adds/removes the open ended panel tab to a course automatically
if the user has indicated that they want to edit the
combinedopendended or peergrading module
2) adds/removes the notes panel tab to a course automatically if
the user has indicated that they want the notes module enabled in
their course
"""
# TODO refactor the above into distinct advanced policy settings
filter_tabs = True # Exceptional conditions will pull this to False
if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components
tab_component_map = {
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
'notes': NOTE_COMPONENT_TYPES,
}
# Check to see if the user instantiated any notes or open ended components
for tab_type in tab_component_map.keys():
component_types = tab_component_map.get(tab_type)
found_ac_type = False
for ac_type in component_types:
# Check if the user has incorrectly failed to put the value in an iterable.
new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value']
if hasattr(new_advanced_component_list, '__iter__'):
if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES:
# Add tab to the course if needed
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
# If a tab has been added to the course, then send the
# metadata along to CourseMetadata.update_from_json
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should not be filtered out of
# the metadata
filter_tabs = False # Set this flag to avoid the tab removal code below.
found_ac_type = True # break
else:
# If not iterable, return immediately and let validation handle.
return
def _add_tab(request, tab_type, course_module):
"""
Adds tab to the course.
"""
# Add tab to the course if needed
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
# If a tab has been added to the course, then send the
# metadata along to CourseMetadata.update_from_json
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should not be filtered out of
# the metadata
return True
return False
# pylint: disable=invalid-name
def _remove_tab(request, tab_type, course_module):
"""
Removes the tab from the course.
"""
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
return True
return False
def is_advanced_component_present(request, advanced_components):
"""
Return True when one of `advanced_components` is present in the request.
raises TypeError
when request.ADVANCED_COMPONENT_POLICY_KEY is malformed (not iterable)
"""
if ADVANCED_COMPONENT_POLICY_KEY not in request.json:
return False
new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value']
for ac_type in advanced_components:
if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES:
return True
# If we did not find a module type in the advanced settings,
# we may need to remove the tab from the course.
if not found_ac_type: # Remove tab from the course if needed
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
if changed:
course_module.tabs = new_tabs
request.json.update({'tabs': {'value': new_tabs}})
# Indicate that tabs should *not* be filtered out of
# the metadata
filter_tabs = False
return filter_tabs
def is_field_value_true(request, field_list):
"""
Return True when one of field values is set to True by request
"""
return any([request.json.get(field, {}).get('value') for field in field_list])
# pylint: disable=invalid-name
def _modify_tabs_to_components(request, course_module):
"""
Automatically adds/removes tabs if user indicated that they want
respective modules enabled in the course
Return True when tab configuration has been modified.
"""
tab_component_map = {
# 'tab_type': (check_function, list_of_checked_components_or_values),
# open ended tab by combinedopendended or peergrading module
'open_ended': (is_advanced_component_present, OPEN_ENDED_COMPONENT_TYPES),
# notes tab
'notes': (is_advanced_component_present, NOTE_COMPONENT_TYPES),
# student notes tab
'edxnotes': (is_field_value_true, ['edxnotes'])
}
tabs_changed = False
for tab_type in tab_component_map.keys():
check, component_types = tab_component_map[tab_type]
try:
tab_enabled = check(request, component_types)
except TypeError:
# user has failed to put iterable value into advanced component list.
# return immediately and let validation handle.
return
if tab_enabled:
# check passed, some of this component_types are present, adding tab
if _add_tab(request, tab_type, course_module):
# tab indeed was added, the change needs to propagate
tabs_changed = True
else:
# the tab should not be present (anymore)
if _remove_tab(request, tab_type, course_module):
# tab indeed was removed, the change needs to propagate
tabs_changed = True
return tabs_changed
@login_required
......@@ -954,8 +992,8 @@ def advanced_settings_handler(request, course_key_string):
return JsonResponse(CourseMetadata.fetch(course_module))
else:
try:
# Whether or not to filter the tabs key out of the settings metadata
filter_tabs = _config_course_advanced_components(request, course_module)
# do not process tabs unless they were modified according to course metadata
filter_tabs = not _modify_tabs_to_components(request, course_module)
# validate data formats and update
is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json(
......
......@@ -47,6 +47,10 @@ class CourseMetadata(object):
if not settings.FEATURES.get('ENABLE_EXPORT_GIT'):
filtered_list.append('giturl')
# Do not show edxnotes if the feature is disabled.
if not settings.FEATURES.get('ENABLE_EDXNOTES'):
filtered_list.append('edxnotes')
return filtered_list
@classmethod
......
......@@ -116,6 +116,11 @@ FEATURES = {
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
'IS_EDX_DOMAIN': False,
# let students save and manage their annotations
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
'ENABLE_EDXNOTES': False,
}
ENABLE_JASMINE = False
......
......@@ -229,3 +229,5 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
FEATURES['ENABLE_EDXNOTES'] = True
define(['js/base', 'coffee/src/main', 'coffee/src/logger', 'datepair', 'accessibility',
define(['js/base', 'coffee/src/main', 'js/src/logger', 'datepair', 'accessibility',
'ieshim', 'tooltip_manager']);
......@@ -220,7 +220,7 @@ require.config({
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/logger": {
"js/src/logger": {
exports: "Logger",
deps: ["coffee/src/ajax_prefix"]
},
......
"""
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
......
"""
Unit tests for stub EdxNotes implementation.
"""
import json
import unittest
import requests
from uuid import uuid4
from ..edxnotes import StubEdxNotesService
class StubEdxNotesServiceTest(unittest.TestCase):
"""
Test cases for the stub EdxNotes service.
"""
def setUp(self):
"""
Start the stub server.
"""
self.server = StubEdxNotesService()
dummy_notes = self._get_dummy_notes(count=2)
self.server.add_notes(dummy_notes)
self.addCleanup(self.server.shutdown)
def _get_dummy_notes(self, count=1):
"""
Returns a list of dummy notes.
"""
return [self._get_dummy_note() for i in xrange(count)] # pylint: disable=unused-variable
def _get_dummy_note(self):
"""
Returns a single dummy note.
"""
nid = uuid4().hex
return {
"id": nid,
"created": "2014-10-31T10:05:00.000000",
"updated": "2014-10-31T10:50:00.101010",
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
"text": "dummy note text " + nid,
"quote": "dummy note quote",
"ranges": [
{
"start": "/p[1]",
"end": "/p[1]",
"startOffset": 0,
"endOffset": 10,
}
],
}
def test_note_create(self):
dummy_note = {
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
"text": "dummy note text",
"quote": "dummy note quote",
"ranges": [
{
"start": "/p[1]",
"end": "/p[1]",
"startOffset": 0,
"endOffset": 10,
}
],
}
response = requests.post(self._get_url("api/v1/annotations"), data=json.dumps(dummy_note))
self.assertTrue(response.ok)
response_content = response.json()
self.assertIn("id", response_content)
self.assertIn("created", response_content)
self.assertIn("updated", response_content)
self.assertIn("annotator_schema_version", response_content)
self.assertDictContainsSubset(dummy_note, response_content)
def test_note_read(self):
notes = self._get_notes()
for note in notes:
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertDictEqual(note, response.json())
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_note_update(self):
notes = self._get_notes()
for note in notes:
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertDictEqual(note, response.json())
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_search(self):
response = requests.get(self._get_url("api/v1/search"), params={
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
})
notes = self._get_notes()
self.assertTrue(response.ok)
self.assertDictEqual({"total": 2, "rows": notes}, response.json())
response = requests.get(self._get_url("api/v1/search"))
self.assertEqual(response.status_code, 400)
def test_delete(self):
notes = self._get_notes()
response = requests.delete(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
for note in notes:
response = requests.delete(self._get_url("api/v1/annotations/" + note["id"]))
self.assertEqual(response.status_code, 204)
remaining_notes = self.server.get_notes()
self.assertNotIn(note["id"], [note["id"] for note in remaining_notes])
self.assertEqual(len(remaining_notes), 0)
def test_update(self):
note = self._get_notes()[0]
response = requests.put(self._get_url("api/v1/annotations/" + note["id"]), data=json.dumps({
"text": "new test text"
}))
self.assertEqual(response.status_code, 200)
updated_note = self._get_notes()[0]
self.assertEqual("new test text", updated_note["text"])
self.assertEqual(note["id"], updated_note["id"])
self.assertItemsEqual(note, updated_note)
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
def test_notes_collection(self):
response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"})
self.assertTrue(response.ok)
self.assertEqual(len(response.json()), 2)
response = requests.get(self._get_url("api/v1/annotations"))
self.assertEqual(response.status_code, 400)
def test_cleanup(self):
response = requests.put(self._get_url("cleanup"))
self.assertTrue(response.ok)
self.assertEqual(len(self.server.get_notes()), 0)
def test_create_notes(self):
dummy_notes = self._get_dummy_notes(count=2)
response = requests.post(self._get_url("create_notes"), data=json.dumps(dummy_notes))
self.assertTrue(response.ok)
self.assertEqual(len(self._get_notes()), 4)
response = requests.post(self._get_url("create_notes"))
self.assertEqual(response.status_code, 400)
def test_headers(self):
note = self._get_notes()[0]
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
self.assertTrue(response.ok)
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
response = requests.options(self._get_url("api/v1/annotations/"))
self.assertTrue(response.ok)
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
self.assertEqual(response.headers.get("access-control-allow-methods"), "GET, POST, PUT, DELETE, OPTIONS")
self.assertIn("X-CSRFToken", response.headers.get("access-control-allow-headers"))
def _get_notes(self):
"""
Return a list of notes from the stub EdxNotes service.
"""
notes = self.server.get_notes()
self.assertGreater(len(notes), 0, "Notes are empty.")
return notes
def _get_url(self, path):
"""
Construt a URL to the stub EdxNotes service.
"""
return "http://127.0.0.1:{port}/{path}/".format(
port=self.server.port, path=path
)
"""
Utilities related to edXNotes.
"""
import sys
def edxnotes(cls):
"""
Conditional decorator that loads edxnotes only when they exist.
"""
if "edxnotes" in sys.modules:
from edxnotes.decorators import edxnotes as notes # pylint: disable=import-error
return notes(cls)
else:
return cls
......@@ -16,6 +16,8 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname
import textwrap
from xmodule.contentstore.content import StaticContent
from xblock.core import XBlock
from xmodule.edxnotes_utils import edxnotes
log = logging.getLogger("edx.courseware")
......@@ -51,7 +53,10 @@ class HtmlFields(object):
)
class HtmlModule(HtmlFields, XModule):
class HtmlModuleMixin(HtmlFields, XModule):
"""
Attributes and methods used by HtmlModules internally.
"""
js = {
'coffee': [
resource_string(__name__, 'js/src/javascript_loader.coffee'),
......@@ -72,6 +77,14 @@ class HtmlModule(HtmlFields, XModule):
return self.data
@edxnotes
class HtmlModule(HtmlModuleMixin):
"""
Module for putting raw html in a course
"""
pass
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
"""
Module for putting raw html in a course
......@@ -255,7 +268,7 @@ class AboutFields(object):
@XBlock.tag("detached")
class AboutModule(AboutFields, HtmlModule):
class AboutModule(AboutFields, HtmlModuleMixin):
"""
Overriding defaults but otherwise treated as HtmlModule.
"""
......@@ -292,7 +305,7 @@ class StaticTabFields(object):
@XBlock.tag("detached")
class StaticTabModule(StaticTabFields, HtmlModule):
class StaticTabModule(StaticTabFields, HtmlModuleMixin):
"""
Supports the field overrides
"""
......@@ -326,7 +339,7 @@ class CourseInfoFields(object):
@XBlock.tag("detached")
class CourseInfoModule(CourseInfoFields, HtmlModule):
class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
"""
Just to support xblock field overrides
"""
......
......@@ -35,7 +35,7 @@ src_paths:
lib_paths:
- common_static/js/test/i18n.js
- common_static/coffee/src/ajax_prefix.js
- common_static/coffee/src/logger.js
- common_static/js/src/logger.js
- common_static/js/vendor/jasmine-jquery.js
- common_static/js/vendor/jasmine-imagediff.js
- common_static/js/vendor/require.js
......
......@@ -34,7 +34,7 @@ describe 'Crowdsourced hinter', ->
response =
success: 'incorrect'
contents: 'mock grader response'
settings.success(response)
settings.success(response) if settings
)
@problem.answers = 'test answer'
@problem.check_fd()
......
......@@ -172,6 +172,19 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings,
default=default_reset_button
)
edxnotes = Boolean(
display_name=_("Enable Student Notes"),
help=_("Enter true or false. If true, students can use the Student Notes feature."),
default=False,
scope=Scope.settings
)
edxnotes_visibility = Boolean(
display_name="Student Notes Visibility",
help=_("Indicates whether Student Notes are visible in the course. "
"Students can also show or hide their notes in the courseware."),
default=True,
scope=Scope.user_info
)
def compute_inherited_metadata(descriptor):
......
......@@ -69,6 +69,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
settings: The configuration settings, including values for:
WIKI_ENABLED
FEATURES['ENABLE_DISCUSSION_SERVICE']
FEATURES['ENABLE_EDXNOTES']
FEATURES['ENABLE_STUDENT_NOTES']
FEATURES['ENABLE_TEXTBOOK']
......@@ -195,6 +196,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
'staff_grading': StaffGradingTab,
'open_ended': OpenEndedGradingTab,
'notes': NotesTab,
'edxnotes': EdxNotesTab,
'syllabus': SyllabusTab,
'instructor': InstructorTab, # not persisted
}
......@@ -694,6 +696,27 @@ class NotesTab(AuthenticatedCourseTab):
return super(NotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
class EdxNotesTab(AuthenticatedCourseTab):
"""
A tab for the course student notes.
"""
type = 'edxnotes'
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
return settings.FEATURES.get('ENABLE_EDXNOTES')
def __init__(self, tab_dict=None):
super(EdxNotesTab, self).__init__(
name=tab_dict['name'] if tab_dict else _('Notes'),
tab_id=self.type,
link_func=link_reverse_func(self.type),
)
@classmethod
def validate(cls, tab_dict, raise_error=True):
return super(EdxNotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
class InstructorTab(StaffTab):
"""
A tab for the course instructors.
......@@ -854,13 +877,13 @@ class CourseTabList(List):
# the following tabs should appear only once
for tab_type in [
CoursewareTab.type,
CourseInfoTab.type,
NotesTab.type,
TextbookTabs.type,
PDFTextbookTabs.type,
HtmlTextbookTabs.type,
]:
CoursewareTab.type,
CourseInfoTab.type,
NotesTab.type,
TextbookTabs.type,
PDFTextbookTabs.type,
HtmlTextbookTabs.type,
EdxNotesTab.type]:
cls._validate_num_tabs_of_type(tabs, tab_type, 1)
@staticmethod
......
......@@ -412,6 +412,40 @@ class InstructorTestCase(TabTestCase):
self.check_can_display_results(tab, for_staff_only=True)
class EdxNotesTestCase(TabTestCase):
"""
Test cases for Notes Tab.
"""
def check_edxnotes_tab(self):
"""
Helper function for verifying the edxnotes tab.
"""
return self.check_tab(
tab_class=tabs.EdxNotesTab,
dict_tab={'type': tabs.EdxNotesTab.type, 'name': 'same'},
expected_link=self.reverse('edxnotes', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.EdxNotesTab.type,
invalid_dict_tab=self.fake_dict_tab,
)
def test_edxnotes_tabs_enabled(self):
"""
Tests that edxnotes tab is shown when feature is enabled.
"""
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
tab = self.check_edxnotes_tab()
self.check_can_display_results(tab, for_authenticated_users_only=True)
def test_edxnotes_tabs_disabled(self):
"""
Tests that edxnotes tab is not shown when feature is disabled.
"""
self.settings.FEATURES['ENABLE_EDXNOTES'] = False
tab = self.check_edxnotes_tab()
self.check_can_display_results(tab, expected_value=False)
class KeyCheckerTestCase(unittest.TestCase):
"""Test cases for KeyChecker class"""
......@@ -473,6 +507,7 @@ class TabListTestCase(TabTestCase):
tabs.TextbookTabs.type,
tabs.PDFTextbookTabs.type,
tabs.HtmlTextbookTabs.type,
tabs.EdxNotesTab.type,
]
for unique_tab_type in unique_tab_types:
......@@ -505,6 +540,7 @@ class TabListTestCase(TabTestCase):
{'type': tabs.OpenEndedGradingTab.type},
{'type': tabs.NotesTab.type, 'name': 'fake_name'},
{'type': tabs.SyllabusTab.type},
{'type': tabs.EdxNotesTab.type, 'name': 'fake_name'},
],
# with external discussion
[
......@@ -565,6 +601,7 @@ class CourseTabListTestCase(TabListTestCase):
self.settings.FEATURES['ENABLE_TEXTBOOK'] = True
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
self.settings.FEATURES['ENABLE_STUDENT_NOTES'] = True
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
self.course.hide_progress_tab = False
# create 1 book per textbook type
......
......@@ -546,7 +546,7 @@ browser and pasting the output. When that file changes, this one should be rege
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
<span class="action-label">Edit</span>
<span class="action-icon"><i class="icon fa fa-pencil"></i></span>
<span class="action-icon"><i class="icon fa fa-pencil-square-o"></i></span>
</a>
</li>
</script>
......
describe 'Logger', ->
it 'expose window.log_event', ->
expect(window.log_event).toBe Logger.log
describe 'log', ->
it 'send a request to log event', ->
spyOn jQuery, 'postWithPrefix'
Logger.log 'example', 'data'
expect(jQuery.postWithPrefix).toHaveBeenCalledWith '/event',
event_type: 'example'
event: '"data"'
page: window.location.href
# Broken with commit 9f75e64? Skipping for now.
xdescribe 'bind', ->
beforeEach ->
Logger.bind()
Courseware.prefix = '/6002x'
afterEach ->
window.onunload = null
it 'bind the onunload event', ->
expect(window.onunload).toEqual jasmine.any(Function)
it 'send a request to log event', ->
spyOn($, 'ajax')
window.onunload()
expect($.ajax).toHaveBeenCalledWith
url: "#{Courseware.prefix}/event",
data:
event_type: 'page_close'
event: ''
page: window.location.href
async: false
class @Logger
# listeners[event_type][element] -> list of callbacks
listeners = {}
@log: (event_type, data, element = null) ->
# Check to see if we're listening for the event type.
if event_type of listeners
# Cool. Do the elements also match?
# null element in the listener dictionary means any element will do.
# null element in the @log call means we don't know the element name.
if null of listeners[event_type]
# Make the callbacks.
for callback in listeners[event_type][null]
callback(event_type, data, element)
else if element of listeners[event_type]
for callback in listeners[event_type][element]
callback(event_type, data, element)
# Regardless of whether any callbacks were made, log this event.
$.postWithPrefix '/event',
event_type: event_type
event: JSON.stringify(data)
page: window.location.href
@listen: (event_type, element, callback) ->
# Add a listener. If you want any element to trigger this listener,
# do element = null
if event_type not of listeners
listeners[event_type] = {}
if element not of listeners[event_type]
listeners[event_type][element] = [callback]
else
listeners[event_type][element].push callback
@bind: ->
window.onunload = ->
$.ajaxWithPrefix
url: "/event"
data:
event_type: 'page_close'
event: ''
page: window.location.href
async: false
# log_event exists for compatibility reasons
# and will soon be deprecated.
@log_event = Logger.log
.annotator-notice,.annotator-filter *,.annotator-widget *{font-family:"Helvetica Neue",Arial,Helvetica,sans-serif;font-weight:normal;text-align:left;margin:0;padding:0;background:0;-webkit-transition:none;-moz-transition:none;-o-transition:none;transition:none;-moz-box-shadow:none;-webkit-box-shadow:none;-o-box-shadow:none;box-shadow:none;color:#909090}.annotator-adder{background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAAAwCAYAAAD+WvNWAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowMzgwMTE3NDA3MjA2ODExODRCQUU5RDY0RTkyQTJDNiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDowOUY5RUFERDYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDowOUY5RUFEQzYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1IE1hY2ludG9zaCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjA1ODAxMTc0MDcyMDY4MTE5MTA5OUIyNDhFRUQ1QkM4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjAzODAxMTc0MDcyMDY4MTE4NEJBRTlENjRFOTJBMkM2Ii8+IDwvcmRmOkRlc2NyaXB0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');background-repeat:no-repeat}.annotator-resize,.annotator-widget::after,.annotator-editor a::after,.annotator-viewer .annotator-controls button,.annotator-viewer .annotator-controls a,.annotator-filter .annotator-filter-navigation button::after,.annotator-filter .annotator-filter-property .annotator-filter-clear{background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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');background-repeat:no-repeat}.annotator-hl{background:rgba(255,255,10,0.3)}.annotator-hl-temporary{background:rgba(0,124,255,0.3)}.annotator-wrapper{position:relative}.annotator-adder,.annotator-outer,.annotator-notice{z-index:1020}.annotator-filter{z-index:1010}.annotator-adder,.annotator-outer,.annotator-widget,.annotator-notice{position:absolute;font-size:10px;line-height:1}.annotator-hide{display:none;visibility:hidden}.annotator-adder{margin-top:-48px;margin-left:-24px;width:48px;height:48px;background-position:left top}.annotator-adder:hover{background-position:center top}.annotator-adder:active{background-position:center right}.annotator-adder button{display:block;width:36px;height:41px;margin:0 auto;border:0;background:0;text-indent:-999em;cursor:pointer}.annotator-outer{width:0;height:0}.annotator-widget{margin:0;padding:0;bottom:15px;left:-18px;min-width:265px;background-color:rgba(251,251,251,0.98);border:1px solid rgba(122,122,122,0.6);-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.2);-moz-box-shadow:0 5px 15px rgba(0,0,0,0.2);-o-box-shadow:0 5px 15px rgba(0,0,0,0.2);box-shadow:0 5px 15px rgba(0,0,0,0.2)}.annotator-invert-x .annotator-widget{left:auto;right:-18px}.annotator-invert-y .annotator-widget{bottom:auto;top:8px}.annotator-widget strong{font-weight:bold}.annotator-widget .annotator-listing,.annotator-widget .annotator-item{padding:0;margin:0;list-style:none}.annotator-widget::after{content:"";display:block;width:18px;height:10px;background-position:0 0;position:absolute;bottom:-10px;left:8px}.annotator-invert-x .annotator-widget::after{left:auto;right:8px}.annotator-invert-y .annotator-widget::after{background-position:0 -15px;bottom:auto;top:-9px}.annotator-widget .annotator-item,.annotator-editor .annotator-item input,.annotator-editor .annotator-item textarea{position:relative;font-size:12px}.annotator-viewer .annotator-item{border-top:2px solid rgba(122,122,122,0.2)}.annotator-widget .annotator-item:first-child{border-top:0}.annotator-editor .annotator-item,.annotator-viewer div{border-top:1px solid rgba(133,133,133,0.11)}.annotator-viewer div{padding:6px 6px}.annotator-viewer .annotator-item ol,.annotator-viewer .annotator-item ul{padding:4px 16px}.annotator-viewer div:first-of-type,.annotator-editor .annotator-item:first-child textarea{padding-top:12px;padding-bottom:12px;color:#3c3c3c;font-size:13px;font-style:italic;line-height:1.3;border-top:0}.annotator-viewer .annotator-controls{position:relative;top:5px;right:5px;padding-left:5px;opacity:0;-webkit-transition:opacity .2s ease-in;-moz-transition:opacity .2s ease-in;-o-transition:opacity .2s ease-in;transition:opacity .2s ease-in;float:right}.annotator-viewer li:hover .annotator-controls,.annotator-viewer li .annotator-controls.annotator-visible{opacity:1}.annotator-viewer .annotator-controls button,.annotator-viewer .annotator-controls a{cursor:pointer;display:inline-block;width:13px;height:13px;margin-left:2px;border:0;opacity:.2;text-indent:-900em;background-color:transparent;outline:0}.annotator-viewer .annotator-controls button:hover,.annotator-viewer .annotator-controls button:focus,.annotator-viewer .annotator-controls a:hover,.annotator-viewer .annotator-controls a:focus{opacity:.9}.annotator-viewer .annotator-controls button:active,.annotator-viewer .annotator-controls a:active{opacity:1}.annotator-viewer .annotator-controls button[disabled]{display:none}.annotator-viewer .annotator-controls .annotator-edit{background-position:0 -60px}.annotator-viewer .annotator-controls .annotator-delete{background-position:0 -75px}.annotator-viewer .annotator-controls .annotator-link{background-position:0 -270px}.annotator-editor .annotator-item{position:relative}.annotator-editor .annotator-item label{top:0;display:inline;cursor:pointer;font-size:12px}.annotator-editor .annotator-item input,.annotator-editor .annotator-item textarea{display:block;min-width:100%;padding:10px 8px;border:0;margin:0;color:#3c3c3c;background:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-o-box-sizing:border-box;box-sizing:border-box;resize:none}.annotator-editor .annotator-item textarea::-webkit-scrollbar{height:8px;width:8px}.annotator-editor .annotator-item textarea::-webkit-scrollbar-track-piece{margin:13px 0 3px;background-color:#e5e5e5;-webkit-border-radius:4px}.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:vertical{height:25px;background-color:#ccc;-webkit-border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.1)}.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:horizontal{width:25px;background-color:#ccc;-webkit-border-radius:4px}.annotator-editor .annotator-item:first-child textarea{min-height:5.5em;-webkit-border-radius:5px 5px 0 0;-moz-border-radius:5px 5px 0 0;-o-border-radius:5px 5px 0 0;border-radius:5px 5px 0 0}.annotator-editor .annotator-item input:focus,.annotator-editor .annotator-item textarea:focus{background-color:#f3f3f3;outline:0}.annotator-editor .annotator-item input[type=radio],.annotator-editor .annotator-item input[type=checkbox]{width:auto;min-width:0;padding:0;display:inline;margin:0 4px 0 0;cursor:pointer}.annotator-editor .annotator-checkbox{padding:8px 6px}.annotator-filter,.annotator-filter .annotator-filter-navigation button,.annotator-editor .annotator-controls{text-align:right;padding:3px;border-top:1px solid #d4d4d4;background-color:#d4d4d4;background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),color-stop(0.6,#dcdcdc),to(#d2d2d2));background-image:-moz-linear-gradient(to bottom,#f5f5f5,#dcdcdc 60%,#d2d2d2);background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#dcdcdc 60%,#d2d2d2);background-image:linear-gradient(to bottom,#f5f5f5,#dcdcdc 60%,#d2d2d2);-webkit-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-moz-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-o-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-webkit-border-radius:0 0 5px 5px;-moz-border-radius:0 0 5px 5px;-o-border-radius:0 0 5px 5px;border-radius:0 0 5px 5px}.annotator-editor.annotator-invert-y .annotator-controls{border-top:0;border-bottom:1px solid #b4b4b4;-webkit-border-radius:5px 5px 0 0;-moz-border-radius:5px 5px 0 0;-o-border-radius:5px 5px 0 0;border-radius:5px 5px 0 0}.annotator-editor a,.annotator-filter .annotator-filter-property label{position:relative;display:inline-block;padding:0 6px 0 22px;color:#363636;text-shadow:0 1px 0 rgba(255,255,255,0.75);text-decoration:none;line-height:24px;font-size:12px;font-weight:bold;border:1px solid #a2a2a2;background-color:#d4d4d4;background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),color-stop(0.5,#d2d2d2),color-stop(0.5,#bebebe),to(#d2d2d2));background-image:-moz-linear-gradient(to bottom,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);background-image:linear-gradient(to bottom,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);-webkit-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-moz-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-o-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-webkit-border-radius:5px;-moz-border-radius:5px;-o-border-radius:5px;border-radius:5px}.annotator-editor a::after{position:absolute;top:50%;left:5px;display:block;content:"";width:15px;height:15px;margin-top:-7px;background-position:0 -90px}.annotator-editor a:hover,.annotator-editor a:focus,.annotator-editor a.annotator-focus,.annotator-filter .annotator-filter-active label,.annotator-filter .annotator-filter-navigation button:hover{outline:0;border-color:#435aa0;background-color:#3865f9;background-image:-webkit-gradient(linear,left top,left bottom,from(#7691fb),color-stop(0.5,#5075fb),color-stop(0.5,#3865f9),to(#3665fa));background-image:-moz-linear-gradient(to bottom,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);background-image:-webkit-linear-gradient(to bottom,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);background-image:linear-gradient(to bottom,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.42)}.annotator-editor a:hover::after,.annotator-editor a:focus::after{margin-top:-8px;background-position:0 -105px}.annotator-editor a:active,.annotator-filter .annotator-filter-navigation button:active{border-color:#700c49;background-color:#d12e8e;background-image:-webkit-gradient(linear,left top,left bottom,from(#fc7cca),color-stop(0.5,#e85db2),color-stop(0.5,#d12e8e),to(#ff009c));background-image:-moz-linear-gradient(to bottom,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c);background-image:-webkit-linear-gradient(to bottom,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c);background-image:linear-gradient(to bottom,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c)}.annotator-editor a.annotator-save::after{background-position:0 -120px}.annotator-editor a.annotator-save:hover::after,.annotator-editor a.annotator-save:focus::after,.annotator-editor a.annotator-save.annotator-focus::after{margin-top:-8px;background-position:0 -135px}.annotator-editor .annotator-widget::after{background-position:0 -30px}.annotator-editor.annotator-invert-y .annotator-widget .annotator-controls{background-color:#f2f2f2}.annotator-editor.annotator-invert-y .annotator-widget::after{background-position:0 -45px;height:11px}.annotator-resize{position:absolute;top:0;right:0;width:12px;height:12px;background-position:2px -150px}.annotator-invert-x .annotator-resize{right:auto;left:0;background-position:0 -195px}.annotator-invert-y .annotator-resize{top:auto;bottom:0;background-position:2px -165px}.annotator-invert-y.annotator-invert-x .annotator-resize{background-position:0 -180px}.annotator-notice{color:#fff;position:absolute;position:fixed;top:-54px;left:0;width:100%;font-size:14px;line-height:50px;text-align:center;background:black;background:rgba(0,0,0,0.9);border-bottom:4px solid #d4d4d4;-webkit-transition:top .4s ease-out;-moz-transition:top .4s ease-out;-o-transition:top .4s ease-out;transition:top .4s ease-out}.ie6 .annotator-notice{position:absolute}.annotator-notice-success{border-color:#3665f9}.annotator-notice-error{border-color:#ff7e00}.annotator-notice p{margin:0}.annotator-notice a{color:#fff}.annotator-notice-show{top:0}.annotator-tags{margin-bottom:-2px}.annotator-tags .annotator-tag{display:inline-block;padding:0 8px;margin-bottom:2px;line-height:1.6;font-weight:bold;background-color:#e6e6e6;-webkit-border-radius:8px;-moz-border-radius:8px;-o-border-radius:8px;border-radius:8px}.annotator-filter{position:fixed;top:0;right:0;left:0;text-align:left;line-height:0;border:0;border-bottom:1px solid #878787;padding-left:10px;padding-right:10px;-webkit-border-radius:0;-moz-border-radius:0;-o-border-radius:0;border-radius:0;-webkit-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);-moz-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);-o-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3)}.annotator-filter strong{font-size:12px;font-weight:bold;color:#3c3c3c;text-shadow:0 1px 0 rgba(255,255,255,0.7);position:relative;top:-9px}.annotator-filter .annotator-filter-property,.annotator-filter .annotator-filter-navigation{position:relative;display:inline-block;overflow:hidden;line-height:10px;padding:2px 0;margin-right:8px}.annotator-filter .annotator-filter-property label,.annotator-filter .annotator-filter-navigation button{text-align:left;display:block;float:left;line-height:20px;-webkit-border-radius:10px 0 0 10px;-moz-border-radius:10px 0 0 10px;-o-border-radius:10px 0 0 10px;border-radius:10px 0 0 10px}.annotator-filter .annotator-filter-property label{padding-left:8px}.annotator-filter .annotator-filter-property input{display:block;float:right;-webkit-appearance:none;background-color:#fff;border:1px solid #878787;border-left:none;padding:2px 4px;line-height:16px;min-height:16px;font-size:12px;width:150px;color:#333;background-color:#f8f8f8;-webkit-border-radius:0 10px 10px 0;-moz-border-radius:0 10px 10px 0;-o-border-radius:0 10px 10px 0;border-radius:0 10px 10px 0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);-o-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);box-shadow:inset 0 1px 1px rgba(0,0,0,0.2)}.annotator-filter .annotator-filter-property input:focus{outline:0;background-color:#fff}.annotator-filter .annotator-filter-clear{position:absolute;right:3px;top:6px;border:0;text-indent:-900em;width:15px;height:15px;background-position:0 -90px;opacity:.4}.annotator-filter .annotator-filter-clear:hover,.annotator-filter .annotator-filter-clear:focus{opacity:.8}.annotator-filter .annotator-filter-clear:active{opacity:1}.annotator-filter .annotator-filter-navigation button{border:1px solid #a2a2a2;padding:0;text-indent:-900px;width:20px;min-height:22px;-webkit-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-moz-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-o-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8)}.annotator-filter .annotator-filter-navigation button,.annotator-filter .annotator-filter-navigation button:hover,.annotator-filter .annotator-filter-navigation button:focus{color:transparent}.annotator-filter .annotator-filter-navigation button::after{position:absolute;top:8px;left:8px;content:"";display:block;width:9px;height:9px;background-position:0 -210px}.annotator-filter .annotator-filter-navigation button:hover::after{background-position:0 -225px}.annotator-filter .annotator-filter-navigation .annotator-filter-next{-webkit-border-radius:0 10px 10px 0;-moz-border-radius:0 10px 10px 0;-o-border-radius:0 10px 10px 0;border-radius:0 10px 10px 0;border-left:none}.annotator-filter .annotator-filter-navigation .annotator-filter-next::after{left:auto;right:7px;background-position:0 -240px}.annotator-filter .annotator-filter-navigation .annotator-filter-next:hover::after{background-position:0 -255px}.annotator-hl-active{background:rgba(255,255,10,0.8)}.annotator-hl-filtered{background-color:transparent}
(function() {
'use strict';
describe('Logger', function() {
it('expose window.log_event', function() {
expect(window.log_event).toBe(Logger.log);
});
describe('log', function() {
it('can send a request to log event', function() {
spyOn(jQuery, 'ajaxWithPrefix');
Logger.log('example', 'data');
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/event',
type: 'POST',
data: {
event_type: 'example',
event: '"data"',
page: window.location.href
},
async: true
});
});
it('can send a request with custom options to log event', function() {
spyOn(jQuery, 'ajaxWithPrefix');
Logger.log('example', 'data', null, {type: 'GET', async: false});
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/event',
type: 'GET',
data: {
event_type: 'example',
event: '"data"',
page: window.location.href
},
async: false
});
});
});
describe('listen', function() {
beforeEach(function () {
spyOn(jQuery, 'ajaxWithPrefix');
this.callbacks = _.map(_.range(4), function () {
return jasmine.createSpy();
});
Logger.listen('example', null, this.callbacks[0]);
Logger.listen('example', null, this.callbacks[1]);
Logger.listen('example', 'element', this.callbacks[2]);
Logger.listen('new_event', null, this.callbacks[3]);
});
it('can listen events when the element name is unknown', function() {
Logger.log('example', 'data');
expect(this.callbacks[0]).toHaveBeenCalledWith('example', 'data', null);
expect(this.callbacks[1]).toHaveBeenCalledWith('example', 'data', null);
expect(this.callbacks[2]).not.toHaveBeenCalled();
expect(this.callbacks[3]).not.toHaveBeenCalled();
});
it('can listen events when the element name is known', function() {
Logger.log('example', 'data', 'element');
expect(this.callbacks[0]).not.toHaveBeenCalled();
expect(this.callbacks[1]).not.toHaveBeenCalled();
expect(this.callbacks[2]).toHaveBeenCalledWith('example', 'data', 'element');
expect(this.callbacks[3]).not.toHaveBeenCalled();
});
});
describe('bind', function() {
beforeEach(function() {
this.initialPostWithPrefix = jQuery.postWithPrefix;
this.initialGetWithPrefix = jQuery.getWithPrefix;
this.initialAjaxWithPrefix = jQuery.ajaxWithPrefix;
this.prefix = '/6002x';
AjaxPrefix.addAjaxPrefix($, _.bind(function () {
return this.prefix;
}, this));
Logger.bind();
});
afterEach(function() {
jQuery.postWithPrefix = this.initialPostWithPrefix;
jQuery.getWithPrefix = this.initialGetWithPrefix;
jQuery.ajaxWithPrefix = this.initialAjaxWithPrefix;
window.onunload = null;
});
it('can bind the onunload event', function() {
expect(window.onunload).toEqual(jasmine.any(Function));
});
it('can send a request to log event', function() {
spyOn(jQuery, 'ajax');
window.onunload();
expect(jQuery.ajax).toHaveBeenCalledWith({
url: this.prefix + '/event',
type: 'GET',
data: {
event_type: 'page_close',
event: '',
page: window.location.href
},
async: false
});
});
});
});
}).call(this);
;(function() {
'use strict';
var Logger = (function() {
// listeners[event_type][element] -> list of callbacks
var listeners = {},
sendRequest, has;
sendRequest = function(data, options) {
var request = $.ajaxWithPrefix ? $.ajaxWithPrefix : $.ajax;
options = $.extend(true, {
'url': '/event',
'type': 'POST',
'data': data,
'async': true
}, options);
return request(options);
};
has = function(object, propertyName) {
return {}.hasOwnProperty.call(object, propertyName);
};
return {
/**
* Emits an event.
*/
log: function(eventType, data, element, requestOptions) {
var callbacks;
if (!element) {
// null element in the listener dictionary means any element will do.
// null element in the Logger.log call means we don't know the element name.
element = null;
}
// Check to see if we're listening for the event type.
if (has(listeners, eventType)) {
if (has(listeners[eventType], element)) {
// Make the callbacks.
callbacks = listeners[eventType][element];
$.each(callbacks, function(index, callback) {
callback(eventType, data, element);
});
}
}
// Regardless of whether any callbacks were made, log this event.
return sendRequest({
'event_type': eventType,
'event': JSON.stringify(data),
'page': window.location.href
}, requestOptions);
},
/**
* Adds a listener. If you want any element to trigger this listener,
* do element = null
*/
listen: function(eventType, element, callback) {
listeners[eventType] = listeners[eventType] || {};
listeners[eventType][element] = listeners[eventType][element] || [];
listeners[eventType][element].push(callback);
},
/**
* Binds `page_close` event.
*/
bind: function() {
window.onunload = function() {
sendRequest({
event_type: 'page_close',
event: '',
page: window.location.href
}, {type: 'GET', async: false});
};
}
};
}());
this.Logger = Logger;
// log_event exists for compatibility reasons and will soon be deprecated.
this.log_event = Logger.log;
}).call(this);
;(function () {
// Trick that provides us possibility to use Django i18n instead of
// using Gettext library needed for Annotator.
var Gettext = function () {
return {
gettext: gettext
};
};
//////////////////////////// Start of the original file ////////////////////////
/*
** Annotator v1.2.9
** https://github.com/okfn/annotator/
**
** Copyright 2013, the Annotator project contributors.
** Dual licensed under the MIT and GPLv3 licenses.
** https://github.com/okfn/annotator/blob/master/LICENSE
**
** Built at: 2013-12-02 17:58:01Z
*/
!function(){var $,Annotator,Delegator,LinkParser,Range,Util,base64Decode,base64UrlDecode,createDateFromISO8601,findChild,fn,functions,g,getNodeName,getNodePosition,gettext,parseToken,simpleXPathJQuery,simpleXPathPure,_Annotator,_gettext,_i,_j,_len,_len1,_ref,_ref1,_t,__slice=[].slice,__hasProp={}.hasOwnProperty,__extends=function(child,parent){for(var key in parent){if(__hasProp.call(parent,key))child[key]=parent[key]}function ctor(){this.constructor=child}ctor.prototype=parent.prototype;child.prototype=new ctor;child.__super__=parent.prototype;return child},__bind=function(fn,me){return function(){return fn.apply(me,arguments)}},__indexOf=[].indexOf||function(item){for(var i=0,l=this.length;i<l;i++){if(i in this&&this[i]===item)return i}return-1};simpleXPathJQuery=function(relativeRoot){var jq;jq=this.map(function(){var elem,idx,path,tagName;path="";elem=this;while((elem!=null?elem.nodeType:void 0)===Node.ELEMENT_NODE&&elem!==relativeRoot){tagName=elem.tagName.replace(":","\\:");idx=$(elem.parentNode).children(tagName).index(elem)+1;idx="["+idx+"]";path="/"+elem.tagName.toLowerCase()+idx+path;elem=elem.parentNode}return path});return jq.get()};simpleXPathPure=function(relativeRoot){var getPathSegment,getPathTo,jq,rootNode;getPathSegment=function(node){var name,pos;name=getNodeName(node);pos=getNodePosition(node);return""+name+"["+pos+"]"};rootNode=relativeRoot;getPathTo=function(node){var xpath;xpath="";while(node!==rootNode){if(node==null){throw new Error("Called getPathTo on a node which was not a descendant of @rootNode. "+rootNode)}xpath=getPathSegment(node)+"/"+xpath;node=node.parentNode}xpath="/"+xpath;xpath=xpath.replace(/\/$/,"");return xpath};jq=this.map(function(){var path;path=getPathTo(this);return path});return jq.get()};findChild=function(node,type,index){var child,children,found,name,_i,_len;if(!node.hasChildNodes()){throw new Error("XPath error: node has no children!")}children=node.childNodes;found=0;for(_i=0,_len=children.length;_i<_len;_i++){child=children[_i];name=getNodeName(child);if(name===type){found+=1;if(found===index){return child}}}throw new Error("XPath error: wanted child not found.")};getNodeName=function(node){var nodeName;nodeName=node.nodeName.toLowerCase();switch(nodeName){case"#text":return"text()";case"#comment":return"comment()";case"#cdata-section":return"cdata-section()";default:return nodeName}};getNodePosition=function(node){var pos,tmp;pos=0;tmp=node;while(tmp){if(tmp.nodeName===node.nodeName){pos++}tmp=tmp.previousSibling}return pos};gettext=null;if(typeof Gettext!=="undefined"&&Gettext!==null){_gettext=new Gettext({domain:"annotator"});gettext=function(msgid){return _gettext.gettext(msgid)}}else{gettext=function(msgid){return msgid}}_t=function(msgid){return gettext(msgid)};if(!(typeof jQuery!=="undefined"&&jQuery!==null?(_ref=jQuery.fn)!=null?_ref.jquery:void 0:void 0)){console.error(_t("Annotator requires jQuery: have you included lib/vendor/jquery.js?"))}if(!(JSON&&JSON.parse&&JSON.stringify)){console.error(_t("Annotator requires a JSON implementation: have you included lib/vendor/json2.js?"))}$=jQuery;Util={};Util.flatten=function(array){var flatten;flatten=function(ary){var el,flat,_i,_len;flat=[];for(_i=0,_len=ary.length;_i<_len;_i++){el=ary[_i];flat=flat.concat(el&&$.isArray(el)?flatten(el):el)}return flat};return flatten(array)};Util.contains=function(parent,child){var node;node=child;while(node!=null){if(node===parent){return true}node=node.parentNode}return false};Util.getTextNodes=function(jq){var getTextNodes;getTextNodes=function(node){var nodes;if(node&&node.nodeType!==Node.TEXT_NODE){nodes=[];if(node.nodeType!==Node.COMMENT_NODE){node=node.lastChild;while(node){nodes.push(getTextNodes(node));node=node.previousSibling}}return nodes.reverse()}else{return node}};return jq.map(function(){return Util.flatten(getTextNodes(this))})};Util.getLastTextNodeUpTo=function(n){var result;switch(n.nodeType){case Node.TEXT_NODE:return n;case Node.ELEMENT_NODE:if(n.lastChild!=null){result=Util.getLastTextNodeUpTo(n.lastChild);if(result!=null){return result}}break}n=n.previousSibling;if(n!=null){return Util.getLastTextNodeUpTo(n)}else{return null}};Util.getFirstTextNodeNotBefore=function(n){var result;switch(n.nodeType){case Node.TEXT_NODE:return n;case Node.ELEMENT_NODE:if(n.firstChild!=null){result=Util.getFirstTextNodeNotBefore(n.firstChild);if(result!=null){return result}}break}n=n.nextSibling;if(n!=null){return Util.getFirstTextNodeNotBefore(n)}else{return null}};Util.readRangeViaSelection=function(range){var sel;sel=Util.getGlobal().getSelection();sel.removeAllRanges();sel.addRange(range.toRange());return sel.toString()};Util.xpathFromNode=function(el,relativeRoot){var exception,result;try{result=simpleXPathJQuery.call(el,relativeRoot)}catch(_error){exception=_error;console.log("jQuery-based XPath construction failed! Falling back to manual.");result=simpleXPathPure.call(el,relativeRoot)}return result};Util.nodeFromXPath=function(xp,root){var idx,name,node,step,steps,_i,_len,_ref1;steps=xp.substring(1).split("/");node=root;for(_i=0,_len=steps.length;_i<_len;_i++){step=steps[_i];_ref1=step.split("["),name=_ref1[0],idx=_ref1[1];idx=idx!=null?parseInt((idx!=null?idx.split("]"):void 0)[0]):1;node=findChild(node,name.toLowerCase(),idx)}return node};Util.escape=function(html){return html.replace(/&(?!\w+;)/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")};Util.uuid=function(){var counter;counter=0;return function(){return counter++}}();Util.getGlobal=function(){return function(){return this}()};Util.maxZIndex=function($elements){var all,el;all=function(){var _i,_len,_results;_results=[];for(_i=0,_len=$elements.length;_i<_len;_i++){el=$elements[_i];if($(el).css("position")==="static"){_results.push(-1)}else{_results.push(parseInt($(el).css("z-index"),10)||-1)}}return _results}();return Math.max.apply(Math,all)};Util.mousePosition=function(e,offsetEl){var offset,_ref1;if((_ref1=$(offsetEl).css("position"))!=="absolute"&&_ref1!=="fixed"&&_ref1!=="relative"){offsetEl=$(offsetEl).offsetParent()[0]}offset=$(offsetEl).offset();return{top:e.pageY-offset.top,left:e.pageX-offset.left}};Util.preventEventDefault=function(event){return event!=null?typeof event.preventDefault==="function"?event.preventDefault():void 0:void 0};functions=["log","debug","info","warn","exception","assert","dir","dirxml","trace","group","groupEnd","groupCollapsed","time","timeEnd","profile","profileEnd","count","clear","table","error","notifyFirebug","firebug","userObjects"];if(typeof console!=="undefined"&&console!==null){if(console.group==null){console.group=function(name){return console.log("GROUP: ",name)}}if(console.groupCollapsed==null){console.groupCollapsed=console.group}for(_i=0,_len=functions.length;_i<_len;_i++){fn=functions[_i];if(console[fn]==null){console[fn]=function(){return console.log(_t("Not implemented:")+(" console."+name))}}}}else{this.console={};for(_j=0,_len1=functions.length;_j<_len1;_j++){fn=functions[_j];this.console[fn]=function(){}}this.console["error"]=function(){var args;args=1<=arguments.length?__slice.call(arguments,0):[];return alert("ERROR: "+args.join(", "))};this.console["warn"]=function(){var args;args=1<=arguments.length?__slice.call(arguments,0):[];return alert("WARNING: "+args.join(", "))}}Delegator=function(){Delegator.prototype.events={};Delegator.prototype.options={};Delegator.prototype.element=null;function Delegator(element,options){this.options=$.extend(true,{},this.options,options);this.element=$(element);this._closures={};this.on=this.subscribe;this.addEvents()}Delegator.prototype.addEvents=function(){var event,_k,_len2,_ref1,_results;_ref1=Delegator._parseEvents(this.events);_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){event=_ref1[_k];_results.push(this._addEvent(event.selector,event.event,event.functionName))}return _results};Delegator.prototype.removeEvents=function(){var event,_k,_len2,_ref1,_results;_ref1=Delegator._parseEvents(this.events);_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){event=_ref1[_k];_results.push(this._removeEvent(event.selector,event.event,event.functionName))}return _results};Delegator.prototype._addEvent=function(selector,event,functionName){var closure;closure=function(_this){return function(){return _this[functionName].apply(_this,arguments)}}(this);if(selector===""&&Delegator._isCustomEvent(event)){this.subscribe(event,closure)}else{this.element.delegate(selector,event,closure)}this._closures[""+selector+"/"+event+"/"+functionName]=closure;return this};Delegator.prototype._removeEvent=function(selector,event,functionName){var closure;closure=this._closures[""+selector+"/"+event+"/"+functionName];if(selector===""&&Delegator._isCustomEvent(event)){this.unsubscribe(event,closure)}else{this.element.undelegate(selector,event,closure)}delete this._closures[""+selector+"/"+event+"/"+functionName];return this};Delegator.prototype.publish=function(){this.element.triggerHandler.apply(this.element,arguments);return this};Delegator.prototype.subscribe=function(event,callback){var closure;closure=function(){return callback.apply(this,[].slice.call(arguments,1))};closure.guid=callback.guid=$.guid+=1;this.element.bind(event,closure);return this};Delegator.prototype.unsubscribe=function(){this.element.unbind.apply(this.element,arguments);return this};return Delegator}();Delegator._parseEvents=function(eventsObj){var event,events,functionName,sel,selector,_k,_ref1;events=[];for(sel in eventsObj){functionName=eventsObj[sel];_ref1=sel.split(" "),selector=2<=_ref1.length?__slice.call(_ref1,0,_k=_ref1.length-1):(_k=0,[]),event=_ref1[_k++];events.push({selector:selector.join(" "),event:event,functionName:functionName})}return events};Delegator.natives=function(){var key,specials,val;specials=function(){var _ref1,_results;_ref1=jQuery.event.special;_results=[];for(key in _ref1){if(!__hasProp.call(_ref1,key))continue;val=_ref1[key];_results.push(key)}return _results}();return"blur focus focusin focusout load resize scroll unload click dblclick\nmousedown mouseup mousemove mouseover mouseout mouseenter mouseleave\nchange select submit keydown keypress keyup error".split(/[^a-z]+/).concat(specials)}();Delegator._isCustomEvent=function(event){event=event.split(".")[0];return $.inArray(event,Delegator.natives)===-1};Range={};Range.sniff=function(r){if(r.commonAncestorContainer!=null){return new Range.BrowserRange(r)}else if(typeof r.start==="string"){return new Range.SerializedRange(r)}else if(r.start&&typeof r.start==="object"){return new Range.NormalizedRange(r)}else{console.error(_t("Could not sniff range type"));return false}};Range.nodeFromXPath=function(xpath,root){var customResolver,evaluateXPath,namespace,node,segment;if(root==null){root=document}evaluateXPath=function(xp,nsResolver){var exception;if(nsResolver==null){nsResolver=null}try{return document.evaluate("."+xp,root,nsResolver,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue}catch(_error){exception=_error;console.log("XPath evaluation failed.");console.log("Trying fallback...");return Util.nodeFromXPath(xp,root)}};if(!$.isXMLDoc(document.documentElement)){return evaluateXPath(xpath)}else{customResolver=document.createNSResolver(document.ownerDocument===null?document.documentElement:document.ownerDocument.documentElement);node=evaluateXPath(xpath,customResolver);if(!node){xpath=function(){var _k,_len2,_ref1,_results;_ref1=xpath.split("/");_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){segment=_ref1[_k];if(segment&&segment.indexOf(":")===-1){_results.push(segment.replace(/^([a-z]+)/,"xhtml:$1"))}else{_results.push(segment)}}return _results}().join("/");namespace=document.lookupNamespaceURI(null);customResolver=function(ns){if(ns==="xhtml"){return namespace}else{return document.documentElement.getAttribute("xmlns:"+ns)}};node=evaluateXPath(xpath,customResolver)}return node}};Range.RangeError=function(_super){__extends(RangeError,_super);function RangeError(type,message,parent){this.type=type;this.message=message;this.parent=parent!=null?parent:null;RangeError.__super__.constructor.call(this,this.message)}return RangeError}(Error);Range.BrowserRange=function(){function BrowserRange(obj){this.commonAncestorContainer=obj.commonAncestorContainer;this.startContainer=obj.startContainer;this.startOffset=obj.startOffset;this.endContainer=obj.endContainer;this.endOffset=obj.endOffset}BrowserRange.prototype.normalize=function(root){var n,node,nr,r;if(this.tainted){console.error(_t("You may only call normalize() once on a BrowserRange!"));return false}else{this.tainted=true}r={};if(this.startContainer.nodeType===Node.ELEMENT_NODE){r.start=Util.getFirstTextNodeNotBefore(this.startContainer.childNodes[this.startOffset]);r.startOffset=0}else{r.start=this.startContainer;r.startOffset=this.startOffset}if(this.endContainer.nodeType===Node.ELEMENT_NODE){node=this.endContainer.childNodes[this.endOffset];if(node!=null){n=node;while(n!=null&&n.nodeType!==Node.TEXT_NODE){n=n.firstChild}if(n!=null){r.end=n;r.endOffset=0}}if(r.end==null){node=this.endContainer.childNodes[this.endOffset-1];r.end=Util.getLastTextNodeUpTo(node);r.endOffset=r.end.nodeValue.length}}else{r.end=this.endContainer;r.endOffset=this.endOffset}nr={};if(r.startOffset>0){if(r.start.nodeValue.length>r.startOffset){nr.start=r.start.splitText(r.startOffset)}else{nr.start=r.start.nextSibling}}else{nr.start=r.start}if(r.start===r.end){if(nr.start.nodeValue.length>r.endOffset-r.startOffset){nr.start.splitText(r.endOffset-r.startOffset)}nr.end=nr.start}else{if(r.end.nodeValue.length>r.endOffset){r.end.splitText(r.endOffset)}nr.end=r.end}nr.commonAncestor=this.commonAncestorContainer;while(nr.commonAncestor.nodeType!==Node.ELEMENT_NODE){nr.commonAncestor=nr.commonAncestor.parentNode}return new Range.NormalizedRange(nr)};BrowserRange.prototype.serialize=function(root,ignoreSelector){return this.normalize(root).serialize(root,ignoreSelector)};return BrowserRange}();Range.NormalizedRange=function(){function NormalizedRange(obj){this.commonAncestor=obj.commonAncestor;this.start=obj.start;this.end=obj.end}NormalizedRange.prototype.normalize=function(root){return this};NormalizedRange.prototype.limit=function(bounds){var nodes,parent,startParents,_k,_len2,_ref1;nodes=$.grep(this.textNodes(),function(node){return node.parentNode===bounds||$.contains(bounds,node.parentNode)});if(!nodes.length){return null}this.start=nodes[0];this.end=nodes[nodes.length-1];startParents=$(this.start).parents();_ref1=$(this.end).parents();for(_k=0,_len2=_ref1.length;_k<_len2;_k++){parent=_ref1[_k];if(startParents.index(parent)!==-1){this.commonAncestor=parent;break}}return this};NormalizedRange.prototype.serialize=function(root,ignoreSelector){var end,serialization,start;serialization=function(node,isEnd){var n,nodes,offset,origParent,textNodes,xpath,_k,_len2;if(ignoreSelector){origParent=$(node).parents(":not("+ignoreSelector+")").eq(0)}else{origParent=$(node).parent()}xpath=Util.xpathFromNode(origParent,root)[0];textNodes=Util.getTextNodes(origParent);nodes=textNodes.slice(0,textNodes.index(node));offset=0;for(_k=0,_len2=nodes.length;_k<_len2;_k++){n=nodes[_k];offset+=n.nodeValue.length}if(isEnd){return[xpath,offset+node.nodeValue.length]}else{return[xpath,offset]}};start=serialization(this.start);end=serialization(this.end,true);return new Range.SerializedRange({start:start[0],end:end[0],startOffset:start[1],endOffset:end[1]})};NormalizedRange.prototype.text=function(){var node;return function(){var _k,_len2,_ref1,_results;_ref1=this.textNodes();_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){node=_ref1[_k];_results.push(node.nodeValue)}return _results}.call(this).join("")};NormalizedRange.prototype.textNodes=function(){var end,start,textNodes,_ref1;textNodes=Util.getTextNodes($(this.commonAncestor));_ref1=[textNodes.index(this.start),textNodes.index(this.end)],start=_ref1[0],end=_ref1[1];return $.makeArray(textNodes.slice(start,+end+1||9e9))};NormalizedRange.prototype.toRange=function(){var range;range=document.createRange();range.setStartBefore(this.start);range.setEndAfter(this.end);return range};return NormalizedRange}();Range.SerializedRange=function(){function SerializedRange(obj){this.start=obj.start;this.startOffset=obj.startOffset;this.end=obj.end;this.endOffset=obj.endOffset}SerializedRange.prototype.normalize=function(root){var contains,e,length,node,p,range,targetOffset,tn,_k,_l,_len2,_len3,_ref1,_ref2;range={};_ref1=["start","end"];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){p=_ref1[_k];try{node=Range.nodeFromXPath(this[p],root)}catch(_error){e=_error;throw new Range.RangeError(p,"Error while finding "+p+" node: "+this[p]+": "+e,e)}if(!node){throw new Range.RangeError(p,"Couldn't find "+p+" node: "+this[p])}length=0;targetOffset=this[p+"Offset"];if(p==="end"){targetOffset--}_ref2=Util.getTextNodes($(node));for(_l=0,_len3=_ref2.length;_l<_len3;_l++){tn=_ref2[_l];if(length+tn.nodeValue.length>targetOffset){range[p+"Container"]=tn;range[p+"Offset"]=this[p+"Offset"]-length;break}else{length+=tn.nodeValue.length}}if(range[p+"Offset"]==null){throw new Range.RangeError(""+p+"offset","Couldn't find offset "+this[p+"Offset"]+" in element "+this[p])}}contains=document.compareDocumentPosition==null?function(a,b){return a.contains(b)}:function(a,b){return a.compareDocumentPosition(b)&16};$(range.startContainer).parents().each(function(){if(contains(this,range.endContainer)){range.commonAncestorContainer=this;return false}});return new Range.BrowserRange(range).normalize(root)};SerializedRange.prototype.serialize=function(root,ignoreSelector){return this.normalize(root).serialize(root,ignoreSelector)};SerializedRange.prototype.toObject=function(){return{start:this.start,startOffset:this.startOffset,end:this.end,endOffset:this.endOffset}};return SerializedRange}();_Annotator=this.Annotator;Annotator=function(_super){__extends(Annotator,_super);Annotator.prototype.events={".annotator-adder button click":"onAdderClick",".annotator-adder button mousedown":"onAdderMousedown",".annotator-hl mouseover":"onHighlightMouseover",".annotator-hl mouseout":"startViewerHideTimer"};Annotator.prototype.html={adder:'<div class="annotator-adder"><button>'+_t("Annotate")+"</button></div>",wrapper:'<div class="annotator-wrapper"></div>'};Annotator.prototype.options={readOnly:false};Annotator.prototype.plugins={};Annotator.prototype.editor=null;Annotator.prototype.viewer=null;Annotator.prototype.selectedRanges=null;Annotator.prototype.mouseIsDown=false;Annotator.prototype.ignoreMouseup=false;Annotator.prototype.viewerHideTimer=null;function Annotator(element,options){this.onDeleteAnnotation=__bind(this.onDeleteAnnotation,this);this.onEditAnnotation=__bind(this.onEditAnnotation,this);this.onAdderClick=__bind(this.onAdderClick,this);this.onAdderMousedown=__bind(this.onAdderMousedown,this);this.onHighlightMouseover=__bind(this.onHighlightMouseover,this);this.checkForEndSelection=__bind(this.checkForEndSelection,this);this.checkForStartSelection=__bind(this.checkForStartSelection,this);this.clearViewerHideTimer=__bind(this.clearViewerHideTimer,this);this.startViewerHideTimer=__bind(this.startViewerHideTimer,this);this.showViewer=__bind(this.showViewer,this);this.onEditorSubmit=__bind(this.onEditorSubmit,this);this.onEditorHide=__bind(this.onEditorHide,this);this.showEditor=__bind(this.showEditor,this);Annotator.__super__.constructor.apply(this,arguments);this.plugins={};if(!Annotator.supported()){return this}if(!this.options.readOnly){this._setupDocumentEvents()}this._setupWrapper()._setupViewer()._setupEditor();this._setupDynamicStyle();this.adder=$(this.html.adder).appendTo(this.wrapper).hide();Annotator._instances.push(this)}Annotator.prototype._setupWrapper=function(){this.wrapper=$(this.html.wrapper);this.element.find("script").remove();this.element.wrapInner(this.wrapper);this.wrapper=this.element.find(".annotator-wrapper");return this};Annotator.prototype._setupViewer=function(){this.viewer=new Annotator.Viewer({readOnly:this.options.readOnly});this.viewer.hide().on("edit",this.onEditAnnotation).on("delete",this.onDeleteAnnotation).addField({load:function(_this){return function(field,annotation){if(annotation.text){$(field).html(Util.escape(annotation.text))}else{$(field).html("<i>"+_t("No Comment")+"</i>")}return _this.publish("annotationViewerTextField",[field,annotation])}}(this)}).element.appendTo(this.wrapper).bind({mouseover:this.clearViewerHideTimer,mouseout:this.startViewerHideTimer});return this};Annotator.prototype._setupEditor=function(){this.editor=new Annotator.Editor;this.editor.hide().on("hide",this.onEditorHide).on("save",this.onEditorSubmit).addField({type:"textarea",label:_t("Comments")+"…",load:function(field,annotation){return $(field).find("textarea").val(annotation.text||"")},submit:function(field,annotation){return annotation.text=$(field).find("textarea").val()}});this.editor.element.appendTo(this.wrapper);return this};Annotator.prototype._setupDocumentEvents=function(){$(document).bind({mouseup:this.checkForEndSelection,mousedown:this.checkForStartSelection});return this};Annotator.prototype._setupDynamicStyle=function(){var max,sel,style,x;style=$("#annotator-dynamic-style");if(!style.length){style=$('<style id="annotator-dynamic-style"></style>').appendTo(document.head)}sel="*"+function(){var _k,_len2,_ref1,_results;_ref1=["adder","outer","notice","filter"];_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){x=_ref1[_k];_results.push(":not(.annotator-"+x+")")}return _results}().join("");max=Util.maxZIndex($(document.body).find(sel));max=Math.max(max,1e3);style.text([".annotator-adder, .annotator-outer, .annotator-notice {"," z-index: "+(max+20)+";","}",".annotator-filter {"," z-index: "+(max+10)+";","}"].join("\n"));return this};Annotator.prototype.destroy=function(){var idx,name,plugin,_ref1;$(document).unbind({mouseup:this.checkForEndSelection,mousedown:this.checkForStartSelection});$("#annotator-dynamic-style").remove();this.adder.remove();this.viewer.destroy();this.editor.destroy();this.wrapper.find(".annotator-hl").each(function(){$(this).contents().insertBefore(this);return $(this).remove()});this.wrapper.contents().insertBefore(this.wrapper);this.wrapper.remove();this.element.data("annotator",null);_ref1=this.plugins;for(name in _ref1){plugin=_ref1[name];this.plugins[name].destroy()}this.removeEvents();idx=Annotator._instances.indexOf(this);if(idx!==-1){return Annotator._instances.splice(idx,1)}};Annotator.prototype.getSelectedRanges=function(){var browserRange,i,normedRange,r,ranges,rangesToIgnore,selection,_k,_len2;selection=Util.getGlobal().getSelection();ranges=[];rangesToIgnore=[];if(!selection.isCollapsed){ranges=function(){var _k,_ref1,_results;_results=[];for(i=_k=0,_ref1=selection.rangeCount;0<=_ref1?_k<_ref1:_k>_ref1;i=0<=_ref1?++_k:--_k){r=selection.getRangeAt(i);browserRange=new Range.BrowserRange(r);normedRange=browserRange.normalize().limit(this.wrapper[0]);if(normedRange===null){rangesToIgnore.push(r)}_results.push(normedRange)}return _results}.call(this);selection.removeAllRanges()}for(_k=0,_len2=rangesToIgnore.length;_k<_len2;_k++){r=rangesToIgnore[_k];selection.addRange(r)}return $.grep(ranges,function(range){if(range){selection.addRange(range.toRange())}return range})};Annotator.prototype.createAnnotation=function(){var annotation;annotation={};this.publish("beforeAnnotationCreated",[annotation]);return annotation};Annotator.prototype.setupAnnotation=function(annotation){var e,normed,normedRanges,r,root,_k,_l,_len2,_len3,_ref1;root=this.wrapper[0];annotation.ranges||(annotation.ranges=this.selectedRanges);normedRanges=[];_ref1=annotation.ranges;for(_k=0,_len2=_ref1.length;_k<_len2;_k++){r=_ref1[_k];try{normedRanges.push(Range.sniff(r).normalize(root))}catch(_error){e=_error;if(e instanceof Range.RangeError){this.publish("rangeNormalizeFail",[annotation,r,e])}else{throw e}}}annotation.quote=[];annotation.ranges=[];annotation.highlights=[];for(_l=0,_len3=normedRanges.length;_l<_len3;_l++){normed=normedRanges[_l];annotation.quote.push($.trim(normed.text()));annotation.ranges.push(normed.serialize(this.wrapper[0],".annotator-hl"));$.merge(annotation.highlights,this.highlightRange(normed))}annotation.quote=annotation.quote.join(" / ");$(annotation.highlights).data("annotation",annotation);return annotation};Annotator.prototype.updateAnnotation=function(annotation){this.publish("beforeAnnotationUpdated",[annotation]);this.publish("annotationUpdated",[annotation]);return annotation};Annotator.prototype.deleteAnnotation=function(annotation){var child,h,_k,_len2,_ref1;if(annotation.highlights!=null){_ref1=annotation.highlights;for(_k=0,_len2=_ref1.length;_k<_len2;_k++){h=_ref1[_k];if(!(h.parentNode!=null)){continue}child=h.childNodes[0];$(h).replaceWith(h.childNodes)}}this.publish("annotationDeleted",[annotation]);return annotation};Annotator.prototype.loadAnnotations=function(annotations){var clone,loader;if(annotations==null){annotations=[]}loader=function(_this){return function(annList){var n,now,_k,_len2;if(annList==null){annList=[]}now=annList.splice(0,10);for(_k=0,_len2=now.length;_k<_len2;_k++){n=now[_k];_this.setupAnnotation(n)}if(annList.length>0){return setTimeout(function(){return loader(annList)},10)}else{return _this.publish("annotationsLoaded",[clone])}}}(this);clone=annotations.slice();loader(annotations);return this};Annotator.prototype.dumpAnnotations=function(){if(this.plugins["Store"]){return this.plugins["Store"].dumpAnnotations()}else{console.warn(_t("Can't dump annotations without Store plugin."));return false}};Annotator.prototype.highlightRange=function(normedRange,cssClass){var hl,node,white,_k,_len2,_ref1,_results;if(cssClass==null){cssClass="annotator-hl"}white=/^\s*$/;hl=$("<span class='"+cssClass+"'></span>");_ref1=normedRange.textNodes();_results=[];for(_k=0,_len2=_ref1.length;_k<_len2;_k++){node=_ref1[_k];if(!white.test(node.nodeValue)){_results.push($(node).wrapAll(hl).parent().show()[0])}}return _results};Annotator.prototype.highlightRanges=function(normedRanges,cssClass){var highlights,r,_k,_len2;if(cssClass==null){cssClass="annotator-hl"}highlights=[];for(_k=0,_len2=normedRanges.length;_k<_len2;_k++){r=normedRanges[_k];$.merge(highlights,this.highlightRange(r,cssClass))}return highlights};Annotator.prototype.addPlugin=function(name,options){var klass,_base;if(this.plugins[name]){console.error(_t("You cannot have more than one instance of any plugin."))}else{klass=Annotator.Plugin[name];if(typeof klass==="function"){this.plugins[name]=new klass(this.element[0],options);this.plugins[name].annotator=this;if(typeof(_base=this.plugins[name]).pluginInit==="function"){_base.pluginInit()}}else{console.error(_t("Could not load ")+name+_t(" plugin. Have you included the appropriate <script> tag?"))}}return this};Annotator.prototype.showEditor=function(annotation,location){this.editor.element.css(location);this.editor.load(annotation);this.publish("annotationEditorShown",[this.editor,annotation]);return this};Annotator.prototype.onEditorHide=function(){this.publish("annotationEditorHidden",[this.editor]);return this.ignoreMouseup=false};Annotator.prototype.onEditorSubmit=function(annotation){return this.publish("annotationEditorSubmit",[this.editor,annotation])};Annotator.prototype.showViewer=function(annotations,location){this.viewer.element.css(location);this.viewer.load(annotations);return this.publish("annotationViewerShown",[this.viewer,annotations])};Annotator.prototype.startViewerHideTimer=function(){if(!this.viewerHideTimer){return this.viewerHideTimer=setTimeout(this.viewer.hide,250)}};Annotator.prototype.clearViewerHideTimer=function(){clearTimeout(this.viewerHideTimer);return this.viewerHideTimer=false};Annotator.prototype.checkForStartSelection=function(event){if(!(event&&this.isAnnotator(event.target))){this.startViewerHideTimer()}return this.mouseIsDown=true};Annotator.prototype.checkForEndSelection=function(event){var container,range,_k,_len2,_ref1;this.mouseIsDown=false;if(this.ignoreMouseup){return}this.selectedRanges=this.getSelectedRanges();_ref1=this.selectedRanges;for(_k=0,_len2=_ref1.length;_k<_len2;_k++){range=_ref1[_k];container=range.commonAncestor;if($(container).hasClass("annotator-hl")){container=$(container).parents("[class!=annotator-hl]")[0]}if(this.isAnnotator(container)){return}}if(event&&this.selectedRanges.length){return this.adder.css(Util.mousePosition(event,this.wrapper[0])).show()}else{return this.adder.hide()}};Annotator.prototype.isAnnotator=function(element){return!!$(element).parents().addBack().filter("[class^=annotator-]").not(this.wrapper).length};Annotator.prototype.onHighlightMouseover=function(event){var annotations;this.clearViewerHideTimer();if(this.mouseIsDown||this.viewer.isShown()){return false}annotations=$(event.target).parents(".annotator-hl").addBack().map(function(){return $(this).data("annotation")});return this.showViewer($.makeArray(annotations),Util.mousePosition(event,this.wrapper[0]))};Annotator.prototype.onAdderMousedown=function(event){if(event!=null){event.preventDefault()}return this.ignoreMouseup=true};Annotator.prototype.onAdderClick=function(event){var annotation,cancel,cleanup,position,save;if(event!=null){event.preventDefault()}position=this.adder.position();this.adder.hide();annotation=this.setupAnnotation(this.createAnnotation());$(annotation.highlights).addClass("annotator-hl-temporary");save=function(_this){return function(){cleanup();$(annotation.highlights).removeClass("annotator-hl-temporary");return _this.publish("annotationCreated",[annotation])}}(this);cancel=function(_this){return function(){cleanup();return _this.deleteAnnotation(annotation)}}(this);cleanup=function(_this){return function(){_this.unsubscribe("annotationEditorHidden",cancel);return _this.unsubscribe("annotationEditorSubmit",save)}}(this);this.subscribe("annotationEditorHidden",cancel);this.subscribe("annotationEditorSubmit",save);return this.showEditor(annotation,position)};Annotator.prototype.onEditAnnotation=function(annotation){var cleanup,offset,update;offset=this.viewer.element.position();update=function(_this){return function(){cleanup();return _this.updateAnnotation(annotation)}}(this);cleanup=function(_this){return function(){_this.unsubscribe("annotationEditorHidden",cleanup);return _this.unsubscribe("annotationEditorSubmit",update)}}(this);this.subscribe("annotationEditorHidden",cleanup);this.subscribe("annotationEditorSubmit",update);this.viewer.hide();return this.showEditor(annotation,offset)};Annotator.prototype.onDeleteAnnotation=function(annotation){this.viewer.hide();return this.deleteAnnotation(annotation)};return Annotator}(Delegator);Annotator.Plugin=function(_super){__extends(Plugin,_super);function Plugin(element,options){Plugin.__super__.constructor.apply(this,arguments)}Plugin.prototype.pluginInit=function(){};Plugin.prototype.destroy=function(){return this.removeEvents()};return Plugin}(Delegator);g=Util.getGlobal();if(((_ref1=g.document)!=null?_ref1.evaluate:void 0)==null){$.getScript("http://assets.annotateit.org/vendor/xpath.min.js")}if(g.getSelection==null){$.getScript("http://assets.annotateit.org/vendor/ierange.min.js")}if(g.JSON==null){$.getScript("http://assets.annotateit.org/vendor/json2.min.js")}if(g.Node==null){g.Node={ELEMENT_NODE:1,ATTRIBUTE_NODE:2,TEXT_NODE:3,CDATA_SECTION_NODE:4,ENTITY_REFERENCE_NODE:5,ENTITY_NODE:6,PROCESSING_INSTRUCTION_NODE:7,COMMENT_NODE:8,DOCUMENT_NODE:9,DOCUMENT_TYPE_NODE:10,DOCUMENT_FRAGMENT_NODE:11,NOTATION_NODE:12}}Annotator.$=$;Annotator.Delegator=Delegator;Annotator.Range=Range;Annotator.Util=Util;Annotator._instances=[];Annotator._t=_t;Annotator.supported=function(){return function(){return!!this.getSelection}()};Annotator.noConflict=function(){Util.getGlobal().Annotator=_Annotator;return this};$.fn.annotator=function(options){var args;args=Array.prototype.slice.call(arguments,1);return this.each(function(){var instance;instance=$.data(this,"annotator");if(instance){return options&&instance[options].apply(instance,args)
}else{instance=new Annotator(this,options);return $.data(this,"annotator",instance)}})};this.Annotator=Annotator;Annotator.Widget=function(_super){__extends(Widget,_super);Widget.prototype.classes={hide:"annotator-hide",invert:{x:"annotator-invert-x",y:"annotator-invert-y"}};function Widget(element,options){Widget.__super__.constructor.apply(this,arguments);this.classes=$.extend({},Annotator.Widget.prototype.classes,this.classes)}Widget.prototype.destroy=function(){this.removeEvents();return this.element.remove()};Widget.prototype.checkOrientation=function(){var current,offset,viewport,widget,window;this.resetOrientation();window=$(Annotator.Util.getGlobal());widget=this.element.children(":first");offset=widget.offset();viewport={top:window.scrollTop(),right:window.width()+window.scrollLeft()};current={top:offset.top,right:offset.left+widget.width()};if(current.top-viewport.top<0){this.invertY()}if(current.right-viewport.right>0){this.invertX()}return this};Widget.prototype.resetOrientation=function(){this.element.removeClass(this.classes.invert.x).removeClass(this.classes.invert.y);return this};Widget.prototype.invertX=function(){this.element.addClass(this.classes.invert.x);return this};Widget.prototype.invertY=function(){this.element.addClass(this.classes.invert.y);return this};Widget.prototype.isInvertedY=function(){return this.element.hasClass(this.classes.invert.y)};Widget.prototype.isInvertedX=function(){return this.element.hasClass(this.classes.invert.x)};return Widget}(Delegator);Annotator.Editor=function(_super){__extends(Editor,_super);Editor.prototype.events={"form submit":"submit",".annotator-save click":"submit",".annotator-cancel click":"hide",".annotator-cancel mouseover":"onCancelButtonMouseover","textarea keydown":"processKeypress"};Editor.prototype.classes={hide:"annotator-hide",focus:"annotator-focus"};Editor.prototype.html='<div class="annotator-outer annotator-editor">\n <form class="annotator-widget">\n <ul class="annotator-listing"></ul>\n <div class="annotator-controls">\n <a href="#cancel" class="annotator-cancel">'+_t("Cancel")+'</a>\n<a href="#save" class="annotator-save annotator-focus">'+_t("Save")+"</a>\n </div>\n </form>\n</div>";Editor.prototype.options={};function Editor(options){this.onCancelButtonMouseover=__bind(this.onCancelButtonMouseover,this);this.processKeypress=__bind(this.processKeypress,this);this.submit=__bind(this.submit,this);this.load=__bind(this.load,this);this.hide=__bind(this.hide,this);this.show=__bind(this.show,this);Editor.__super__.constructor.call(this,$(this.html)[0],options);this.fields=[];this.annotation={}}Editor.prototype.show=function(event){Annotator.Util.preventEventDefault(event);this.element.removeClass(this.classes.hide);this.element.find(".annotator-save").addClass(this.classes.focus);this.checkOrientation();this.element.find(":input:first").focus();this.setupDraggables();return this.publish("show")};Editor.prototype.hide=function(event){Annotator.Util.preventEventDefault(event);this.element.addClass(this.classes.hide);return this.publish("hide")};Editor.prototype.load=function(annotation){var field,_k,_len2,_ref2;this.annotation=annotation;this.publish("load",[this.annotation]);_ref2=this.fields;for(_k=0,_len2=_ref2.length;_k<_len2;_k++){field=_ref2[_k];field.load(field.element,this.annotation)}return this.show()};Editor.prototype.submit=function(event){var field,_k,_len2,_ref2;Annotator.Util.preventEventDefault(event);_ref2=this.fields;for(_k=0,_len2=_ref2.length;_k<_len2;_k++){field=_ref2[_k];field.submit(field.element,this.annotation)}this.publish("save",[this.annotation]);return this.hide()};Editor.prototype.addField=function(options){var element,field,input;field=$.extend({id:"annotator-field-"+Annotator.Util.uuid(),type:"input",label:"",load:function(){},submit:function(){}},options);input=null;element=$('<li class="annotator-item" />');field.element=element[0];switch(field.type){case"textarea":input=$("<textarea />");break;case"input":case"checkbox":input=$("<input />");break;case"select":input=$("<select />")}element.append(input);input.attr({id:field.id,placeholder:field.label});if(field.type==="checkbox"){input[0].type="checkbox";element.addClass("annotator-checkbox");element.append($("<label />",{"for":field.id,html:field.label}))}this.element.find("ul:first").append(element);this.fields.push(field);return field.element};Editor.prototype.checkOrientation=function(){var controls,list;Editor.__super__.checkOrientation.apply(this,arguments);list=this.element.find("ul");controls=this.element.find(".annotator-controls");if(this.element.hasClass(this.classes.invert.y)){controls.insertBefore(list)}else if(controls.is(":first-child")){controls.insertAfter(list)}return this};Editor.prototype.processKeypress=function(event){if(event.keyCode===27){return this.hide()}else if(event.keyCode===13&&!event.shiftKey){return this.submit()}};Editor.prototype.onCancelButtonMouseover=function(){return this.element.find("."+this.classes.focus).removeClass(this.classes.focus)};Editor.prototype.setupDraggables=function(){var classes,controls,cornerItem,editor,mousedown,onMousedown,onMousemove,onMouseup,resize,textarea,throttle;this.element.find(".annotator-resize").remove();if(this.element.hasClass(this.classes.invert.y)){cornerItem=this.element.find(".annotator-item:last")}else{cornerItem=this.element.find(".annotator-item:first")}if(cornerItem){$('<span class="annotator-resize"></span>').appendTo(cornerItem)}mousedown=null;classes=this.classes;editor=this.element;textarea=null;resize=editor.find(".annotator-resize");controls=editor.find(".annotator-controls");throttle=false;onMousedown=function(event){if(event.target===this){mousedown={element:this,top:event.pageY,left:event.pageX};textarea=editor.find("textarea:first");$(window).bind({"mouseup.annotator-editor-resize":onMouseup,"mousemove.annotator-editor-resize":onMousemove});return event.preventDefault()}};onMouseup=function(){mousedown=null;return $(window).unbind(".annotator-editor-resize")};onMousemove=function(_this){return function(event){var diff,directionX,directionY,height,width;if(mousedown&&throttle===false){diff={top:event.pageY-mousedown.top,left:event.pageX-mousedown.left};if(mousedown.element===resize[0]){height=textarea.outerHeight();width=textarea.outerWidth();directionX=editor.hasClass(classes.invert.x)?-1:1;directionY=editor.hasClass(classes.invert.y)?1:-1;textarea.height(height+diff.top*directionY);textarea.width(width+diff.left*directionX);if(textarea.outerHeight()!==height){mousedown.top=event.pageY}if(textarea.outerWidth()!==width){mousedown.left=event.pageX}}else if(mousedown.element===controls[0]){editor.css({top:parseInt(editor.css("top"),10)+diff.top,left:parseInt(editor.css("left"),10)+diff.left});mousedown.top=event.pageY;mousedown.left=event.pageX}throttle=true;return setTimeout(function(){return throttle=false},1e3/60)}}}(this);resize.bind("mousedown",onMousedown);return controls.bind("mousedown",onMousedown)};return Editor}(Annotator.Widget);Annotator.Viewer=function(_super){__extends(Viewer,_super);Viewer.prototype.events={".annotator-edit click":"onEditClick",".annotator-delete click":"onDeleteClick"};Viewer.prototype.classes={hide:"annotator-hide",showControls:"annotator-visible"};Viewer.prototype.html={element:'<div class="annotator-outer annotator-viewer">\n <ul class="annotator-widget annotator-listing"></ul>\n</div>',item:'<li class="annotator-annotation annotator-item">\n <span class="annotator-controls">\n <a href="#" title="View as webpage" class="annotator-link">View as webpage</a>\n <button title="Edit" class="annotator-edit">Edit</button>\n <button title="Delete" class="annotator-delete">Delete</button>\n </span>\n</li>'};Viewer.prototype.options={readOnly:false};function Viewer(options){this.onDeleteClick=__bind(this.onDeleteClick,this);this.onEditClick=__bind(this.onEditClick,this);this.load=__bind(this.load,this);this.hide=__bind(this.hide,this);this.show=__bind(this.show,this);Viewer.__super__.constructor.call(this,$(this.html.element)[0],options);this.item=$(this.html.item)[0];this.fields=[];this.annotations=[]}Viewer.prototype.show=function(event){var controls;Annotator.Util.preventEventDefault(event);controls=this.element.find(".annotator-controls").addClass(this.classes.showControls);setTimeout(function(_this){return function(){return controls.removeClass(_this.classes.showControls)}}(this),500);this.element.removeClass(this.classes.hide);return this.checkOrientation().publish("show")};Viewer.prototype.isShown=function(){return!this.element.hasClass(this.classes.hide)};Viewer.prototype.hide=function(event){Annotator.Util.preventEventDefault(event);this.element.addClass(this.classes.hide);return this.publish("hide")};Viewer.prototype.load=function(annotations){var annotation,controller,controls,del,edit,element,field,item,link,links,list,_k,_l,_len2,_len3,_ref2,_ref3;this.annotations=annotations||[];list=this.element.find("ul:first").empty();_ref2=this.annotations;for(_k=0,_len2=_ref2.length;_k<_len2;_k++){annotation=_ref2[_k];item=$(this.item).clone().appendTo(list).data("annotation",annotation);controls=item.find(".annotator-controls");link=controls.find(".annotator-link");edit=controls.find(".annotator-edit");del=controls.find(".annotator-delete");links=new LinkParser(annotation.links||[]).get("alternate",{type:"text/html"});if(links.length===0||links[0].href==null){link.remove()}else{link.attr("href",links[0].href)}if(this.options.readOnly){edit.remove();del.remove()}else{controller={showEdit:function(){return edit.removeAttr("disabled")},hideEdit:function(){return edit.attr("disabled","disabled")},showDelete:function(){return del.removeAttr("disabled")},hideDelete:function(){return del.attr("disabled","disabled")}}}_ref3=this.fields;for(_l=0,_len3=_ref3.length;_l<_len3;_l++){field=_ref3[_l];element=$(field.element).clone().appendTo(item)[0];field.load(element,annotation,controller)}}this.publish("load",[this.annotations]);return this.show()};Viewer.prototype.addField=function(options){var field;field=$.extend({load:function(){}},options);field.element=$("<div />")[0];this.fields.push(field);field.element;return this};Viewer.prototype.onEditClick=function(event){return this.onButtonClick(event,"edit")};Viewer.prototype.onDeleteClick=function(event){return this.onButtonClick(event,"delete")};Viewer.prototype.onButtonClick=function(event,type){var item;item=$(event.target).parents(".annotator-annotation");return this.publish(type,[item.data("annotation")])};return Viewer}(Annotator.Widget);LinkParser=function(){function LinkParser(data){this.data=data}LinkParser.prototype.get=function(rel,cond){var d,k,keys,match,v,_k,_len2,_ref2,_results;if(cond==null){cond={}}cond=$.extend({},cond,{rel:rel});keys=function(){var _results;_results=[];for(k in cond){if(!__hasProp.call(cond,k))continue;v=cond[k];_results.push(k)}return _results}();_ref2=this.data;_results=[];for(_k=0,_len2=_ref2.length;_k<_len2;_k++){d=_ref2[_k];match=keys.reduce(function(m,k){return m&&d[k]===cond[k]},true);if(match){_results.push(d)}else{continue}}return _results};return LinkParser}();Annotator=Annotator||{};Annotator.Notification=function(_super){__extends(Notification,_super);Notification.prototype.events={click:"hide"};Notification.prototype.options={html:"<div class='annotator-notice'></div>",classes:{show:"annotator-notice-show",info:"annotator-notice-info",success:"annotator-notice-success",error:"annotator-notice-error"}};function Notification(options){this.hide=__bind(this.hide,this);this.show=__bind(this.show,this);Notification.__super__.constructor.call(this,$(this.options.html).appendTo(document.body)[0],options)}Notification.prototype.show=function(message,status){if(status==null){status=Annotator.Notification.INFO}this.currentStatus=status;$(this.element).addClass(this.options.classes.show).addClass(this.options.classes[this.currentStatus]).html(Util.escape(message||""));setTimeout(this.hide,5e3);return this};Notification.prototype.hide=function(){if(this.currentStatus==null){this.currentStatus=Annotator.Notification.INFO}$(this.element).removeClass(this.options.classes.show).removeClass(this.options.classes[this.currentStatus]);return this};return Notification}(Delegator);Annotator.Notification.INFO="info";Annotator.Notification.SUCCESS="success";Annotator.Notification.ERROR="error";$(function(){var notification;notification=new Annotator.Notification;Annotator.showNotification=notification.show;return Annotator.hideNotification=notification.hide});Annotator.Plugin.Unsupported=function(_super){__extends(Unsupported,_super);function Unsupported(){return Unsupported.__super__.constructor.apply(this,arguments)}Unsupported.prototype.options={message:Annotator._t("Sorry your current browser does not support the Annotator")};Unsupported.prototype.pluginInit=function(){if(!Annotator.supported()){return $(function(_this){return function(){Annotator.showNotification(_this.options.message);if(window.XMLHttpRequest===void 0&&ActiveXObject!==void 0){return $("html").addClass("ie6")}}}(this))}};return Unsupported}(Annotator.Plugin);createDateFromISO8601=function(string){var d,date,offset,regexp,time,_ref2;regexp="([0-9]{4})(-([0-9]{2})(-([0-9]{2})"+"(T([0-9]{2}):([0-9]{2})(:([0-9]{2})(\\.([0-9]+))?)?"+"(Z|(([-+])([0-9]{2}):([0-9]{2})))?)?)?)?";d=string.match(new RegExp(regexp));offset=0;date=new Date(d[1],0,1);if(d[3]){date.setMonth(d[3]-1)}if(d[5]){date.setDate(d[5])}if(d[7]){date.setHours(d[7])}if(d[8]){date.setMinutes(d[8])}if(d[10]){date.setSeconds(d[10])}if(d[12]){date.setMilliseconds(Number("0."+d[12])*1e3)}if(d[14]){offset=Number(d[16])*60+Number(d[17]);offset*=(_ref2=d[15]==="-")!=null?_ref2:{1:-1}}offset-=date.getTimezoneOffset();time=Number(date)+offset*60*1e3;date.setTime(Number(time));return date};base64Decode=function(data){var ac,b64,bits,dec,h1,h2,h3,h4,i,o1,o2,o3,tmp_arr;if(typeof atob!=="undefined"&&atob!==null){return atob(data)}else{b64="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";i=0;ac=0;dec="";tmp_arr=[];if(!data){return data}data+="";while(i<data.length){h1=b64.indexOf(data.charAt(i++));h2=b64.indexOf(data.charAt(i++));h3=b64.indexOf(data.charAt(i++));h4=b64.indexOf(data.charAt(i++));bits=h1<<18|h2<<12|h3<<6|h4;o1=bits>>16&255;o2=bits>>8&255;o3=bits&255;if(h3===64){tmp_arr[ac++]=String.fromCharCode(o1)}else if(h4===64){tmp_arr[ac++]=String.fromCharCode(o1,o2)}else{tmp_arr[ac++]=String.fromCharCode(o1,o2,o3)}}return tmp_arr.join("")}};base64UrlDecode=function(data){var i,m,_k,_ref2;m=data.length%4;if(m!==0){for(i=_k=0,_ref2=4-m;0<=_ref2?_k<_ref2:_k>_ref2;i=0<=_ref2?++_k:--_k){data+="="}}data=data.replace(/-/g,"+");data=data.replace(/_/g,"/");return base64Decode(data)};parseToken=function(token){var head,payload,sig,_ref2;_ref2=token.split("."),head=_ref2[0],payload=_ref2[1],sig=_ref2[2];return JSON.parse(base64UrlDecode(payload))};Annotator.Plugin.Auth=function(_super){__extends(Auth,_super);Auth.prototype.options={token:null,tokenUrl:"/auth/token",autoFetch:true};function Auth(element,options){Auth.__super__.constructor.apply(this,arguments);this.waitingForToken=[];if(this.options.token){this.setToken(this.options.token)}else{this.requestToken()}}Auth.prototype.requestToken=function(){this.requestInProgress=true;return $.ajax({url:this.options.tokenUrl,dataType:"text",xhrFields:{withCredentials:true}}).done(function(_this){return function(data,status,xhr){return _this.setToken(data)}}(this)).fail(function(_this){return function(xhr,status,err){var msg;msg=Annotator._t("Couldn't get auth token:");console.error(""+msg+" "+err,xhr);return Annotator.showNotification(""+msg+" "+xhr.responseText,Annotator.Notification.ERROR)}}(this)).always(function(_this){return function(){return _this.requestInProgress=false}}(this))};Auth.prototype.setToken=function(token){var _results;this.token=token;this._unsafeToken=parseToken(token);if(this.haveValidToken()){if(this.options.autoFetch){this.refreshTimeout=setTimeout(function(_this){return function(){return _this.requestToken()}}(this),(this.timeToExpiry()-2)*1e3)}this.updateHeaders();_results=[];while(this.waitingForToken.length>0){_results.push(this.waitingForToken.pop()(this._unsafeToken))}return _results}else{console.warn(Annotator._t("Didn't get a valid token."));if(this.options.autoFetch){console.warn(Annotator._t("Getting a new token in 10s."));return setTimeout(function(_this){return function(){return _this.requestToken()}}(this),10*1e3)}}};Auth.prototype.haveValidToken=function(){var allFields;allFields=this._unsafeToken&&this._unsafeToken.issuedAt&&this._unsafeToken.ttl&&this._unsafeToken.consumerKey;if(allFields&&this.timeToExpiry()>0){return true}else{return false}};Auth.prototype.timeToExpiry=function(){var expiry,issue,now,timeToExpiry;now=(new Date).getTime()/1e3;issue=createDateFromISO8601(this._unsafeToken.issuedAt).getTime()/1e3;expiry=issue+this._unsafeToken.ttl;timeToExpiry=expiry-now;if(timeToExpiry>0){return timeToExpiry}else{return 0}};Auth.prototype.updateHeaders=function(){var current;current=this.element.data("annotator:headers");return this.element.data("annotator:headers",$.extend(current,{"x-annotator-auth-token":this.token}))};Auth.prototype.withToken=function(callback){if(callback==null){return}if(this.haveValidToken()){return callback(this._unsafeToken)}else{this.waitingForToken.push(callback);if(!this.requestInProgress){return this.requestToken()}}};return Auth}(Annotator.Plugin);Annotator.Plugin.Store=function(_super){__extends(Store,_super);Store.prototype.events={annotationCreated:"annotationCreated",annotationDeleted:"annotationDeleted",annotationUpdated:"annotationUpdated"};Store.prototype.options={annotationData:{},emulateHTTP:false,loadFromSearch:false,prefix:"/store",urls:{create:"/annotations",read:"/annotations/:id",update:"/annotations/:id",destroy:"/annotations/:id",search:"/search"}};function Store(element,options){this._onError=__bind(this._onError,this);this._onLoadAnnotationsFromSearch=__bind(this._onLoadAnnotationsFromSearch,this);this._onLoadAnnotations=__bind(this._onLoadAnnotations,this);this._getAnnotations=__bind(this._getAnnotations,this);Store.__super__.constructor.apply(this,arguments);this.annotations=[]}Store.prototype.pluginInit=function(){if(!Annotator.supported()){return}if(this.annotator.plugins.Auth){return this.annotator.plugins.Auth.withToken(this._getAnnotations)}else{return this._getAnnotations()}};Store.prototype._getAnnotations=function(){if(this.options.loadFromSearch){return this.loadAnnotationsFromSearch(this.options.loadFromSearch)}else{return this.loadAnnotations()}};Store.prototype.annotationCreated=function(annotation){if(__indexOf.call(this.annotations,annotation)<0){this.registerAnnotation(annotation);return this._apiRequest("create",annotation,function(_this){return function(data){if(data.id==null){console.warn(Annotator._t("Warning: No ID returned from server for annotation "),annotation)}return _this.updateAnnotation(annotation,data)}}(this))}else{return this.updateAnnotation(annotation,{})}};Store.prototype.annotationUpdated=function(annotation){if(__indexOf.call(this.annotations,annotation)>=0){return this._apiRequest("update",annotation,function(_this){return function(data){return _this.updateAnnotation(annotation,data)}}(this))}};Store.prototype.annotationDeleted=function(annotation){if(__indexOf.call(this.annotations,annotation)>=0){return this._apiRequest("destroy",annotation,function(_this){return function(){return _this.unregisterAnnotation(annotation)}}(this))}};Store.prototype.registerAnnotation=function(annotation){return this.annotations.push(annotation)};Store.prototype.unregisterAnnotation=function(annotation){return this.annotations.splice(this.annotations.indexOf(annotation),1)};Store.prototype.updateAnnotation=function(annotation,data){if(__indexOf.call(this.annotations,annotation)<0){console.error(Annotator._t("Trying to update unregistered annotation!"))}else{$.extend(annotation,data)}return $(annotation.highlights).data("annotation",annotation)};Store.prototype.loadAnnotations=function(){return this._apiRequest("read",null,this._onLoadAnnotations)};Store.prototype._onLoadAnnotations=function(data){var a,annotation,annotationMap,newData,_k,_l,_len2,_len3,_ref2;if(data==null){data=[]}annotationMap={};_ref2=this.annotations;for(_k=0,_len2=_ref2.length;_k<_len2;_k++){a=_ref2[_k];annotationMap[a.id]=a}newData=[];for(_l=0,_len3=data.length;_l<_len3;_l++){a=data[_l];if(annotationMap[a.id]){annotation=annotationMap[a.id];this.updateAnnotation(annotation,a)}else{newData.push(a)}}this.annotations=this.annotations.concat(newData);return this.annotator.loadAnnotations(newData.slice())};Store.prototype.loadAnnotationsFromSearch=function(searchOptions){return this._apiRequest("search",searchOptions,this._onLoadAnnotationsFromSearch)};Store.prototype._onLoadAnnotationsFromSearch=function(data){if(data==null){data={}}return this._onLoadAnnotations(data.rows||[])};Store.prototype.dumpAnnotations=function(){var ann,_k,_len2,_ref2,_results;_ref2=this.annotations;_results=[];for(_k=0,_len2=_ref2.length;_k<_len2;_k++){ann=_ref2[_k];_results.push(JSON.parse(this._dataFor(ann)))}return _results};Store.prototype._apiRequest=function(action,obj,onSuccess){var id,options,request,url;id=obj&&obj.id;url=this._urlFor(action,id);options=this._apiRequestOptions(action,obj,onSuccess);request=$.ajax(url,options);request._id=id;request._action=action;return request};Store.prototype._apiRequestOptions=function(action,obj,onSuccess){var data,method,opts;method=this._methodFor(action);opts={type:method,headers:this.element.data("annotator:headers"),dataType:"json",success:onSuccess||function(){},error:this._onError};if(this.options.emulateHTTP&&(method==="PUT"||method==="DELETE")){opts.headers=$.extend(opts.headers,{"X-HTTP-Method-Override":method});opts.type="POST"}if(action==="search"){opts=$.extend(opts,{data:obj});return opts}data=obj&&this._dataFor(obj);if(this.options.emulateJSON){opts.data={json:data};if(this.options.emulateHTTP){opts.data._method=method}return opts}opts=$.extend(opts,{data:data,contentType:"application/json; charset=utf-8"});return opts};Store.prototype._urlFor=function(action,id){var url;url=this.options.prefix!=null?this.options.prefix:"";url+=this.options.urls[action];url=url.replace(/\/:id/,id!=null?"/"+id:"");url=url.replace(/:id/,id!=null?id:"");return url};Store.prototype._methodFor=function(action){var table;table={create:"POST",read:"GET",update:"PUT",destroy:"DELETE",search:"GET"};return table[action]};Store.prototype._dataFor=function(annotation){var data,highlights;highlights=annotation.highlights;delete annotation.highlights;$.extend(annotation,this.options.annotationData);data=JSON.stringify(annotation);if(highlights){annotation.highlights=highlights}return data};Store.prototype._onError=function(xhr){var action,message;action=xhr._action;message=Annotator._t("Sorry we could not ")+action+Annotator._t(" this annotation");if(xhr._action==="search"){message=Annotator._t("Sorry we could not search the store for annotations")}else if(xhr._action==="read"&&!xhr._id){message=Annotator._t("Sorry we could not ")+action+Annotator._t(" the annotations from the store")}switch(xhr.status){case 401:message=Annotator._t("Sorry you are not allowed to ")+action+Annotator._t(" this annotation");break;case 404:message=Annotator._t("Sorry we could not connect to the annotations store");break;case 500:message=Annotator._t("Sorry something went wrong with the annotation store")}Annotator.showNotification(message,Annotator.Notification.ERROR);return console.error(Annotator._t("API request failed:")+(" '"+xhr.status+"'"))};return Store}(Annotator.Plugin);Annotator.Plugin.Permissions=function(_super){__extends(Permissions,_super);Permissions.prototype.events={beforeAnnotationCreated:"addFieldsToAnnotation"};Permissions.prototype.options={showViewPermissionsCheckbox:true,showEditPermissionsCheckbox:true,userId:function(user){return user},userString:function(user){return user},userAuthorize:function(action,annotation,user){var token,tokens,_k,_len2;if(annotation.permissions){tokens=annotation.permissions[action]||[];if(tokens.length===0){return true}for(_k=0,_len2=tokens.length;_k<_len2;_k++){token=tokens[_k];if(this.userId(user)===token){return true}}return false}else if(annotation.user){if(user){return this.userId(user)===this.userId(annotation.user)}else{return false}}return true},user:"",permissions:{read:[],update:[],"delete":[],admin:[]}};function Permissions(element,options){this._setAuthFromToken=__bind(this._setAuthFromToken,this);this.updateViewer=__bind(this.updateViewer,this);this.updateAnnotationPermissions=__bind(this.updateAnnotationPermissions,this);this.updatePermissionsField=__bind(this.updatePermissionsField,this);this.addFieldsToAnnotation=__bind(this.addFieldsToAnnotation,this);Permissions.__super__.constructor.apply(this,arguments);if(this.options.user){this.setUser(this.options.user);delete this.options.user}}Permissions.prototype.pluginInit=function(){var createCallback,self;if(!Annotator.supported()){return}self=this;createCallback=function(method,type){return function(field,annotation){return self[method].call(self,type,field,annotation)}};if(!this.user&&this.annotator.plugins.Auth){this.annotator.plugins.Auth.withToken(this._setAuthFromToken)}if(this.options.showViewPermissionsCheckbox===true){this.annotator.editor.addField({type:"checkbox",label:Annotator._t("Allow anyone to <strong>view</strong> this annotation"),load:createCallback("updatePermissionsField","read"),submit:createCallback("updateAnnotationPermissions","read")})}if(this.options.showEditPermissionsCheckbox===true){this.annotator.editor.addField({type:"checkbox",label:Annotator._t("Allow anyone to <strong>edit</strong> this annotation"),load:createCallback("updatePermissionsField","update"),submit:createCallback("updateAnnotationPermissions","update")})}this.annotator.viewer.addField({load:this.updateViewer});if(this.annotator.plugins.Filter){return this.annotator.plugins.Filter.addFilter({label:Annotator._t("User"),property:"user",isFiltered:function(_this){return function(input,user){var keyword,_k,_len2,_ref2;user=_this.options.userString(user);if(!(input&&user)){return false}_ref2=input.split(/\s*/);for(_k=0,_len2=_ref2.length;_k<_len2;_k++){keyword=_ref2[_k];if(user.indexOf(keyword)===-1){return false}}return true}}(this)})}};Permissions.prototype.setUser=function(user){return this.user=user};Permissions.prototype.addFieldsToAnnotation=function(annotation){if(annotation){annotation.permissions=this.options.permissions;if(this.user){return annotation.user=this.user}}};Permissions.prototype.authorize=function(action,annotation,user){if(user===void 0){user=this.user}if(this.options.userAuthorize){return this.options.userAuthorize.call(this.options,action,annotation,user)}else{return true}};Permissions.prototype.updatePermissionsField=function(action,field,annotation){var input;field=$(field).show();input=field.find("input").removeAttr("disabled");if(!this.authorize("admin",annotation)){field.hide()}if(this.authorize(action,annotation||{},null)){return input.attr("checked","checked")}else{return input.removeAttr("checked")}};Permissions.prototype.updateAnnotationPermissions=function(type,field,annotation){var dataKey;if(!annotation.permissions){annotation.permissions=this.options.permissions}dataKey=type+"-permissions";if($(field).find("input").is(":checked")){return annotation.permissions[type]=[]}else{return annotation.permissions[type]=[this.options.userId(this.user)]}};Permissions.prototype.updateViewer=function(field,annotation,controls){var user,username;field=$(field);username=this.options.userString(annotation.user);if(annotation.user&&username&&typeof username==="string"){user=Annotator.Util.escape(this.options.userString(annotation.user));field.html(user).addClass("annotator-user")}else{field.remove()}if(controls){if(!this.authorize("update",annotation)){controls.hideEdit()}if(!this.authorize("delete",annotation)){return controls.hideDelete()}}};Permissions.prototype._setAuthFromToken=function(token){return this.setUser(token.userId)};return Permissions}(Annotator.Plugin);Annotator.Plugin.AnnotateItPermissions=function(_super){__extends(AnnotateItPermissions,_super);function AnnotateItPermissions(){this._setAuthFromToken=__bind(this._setAuthFromToken,this);this.updateAnnotationPermissions=__bind(this.updateAnnotationPermissions,this);this.updatePermissionsField=__bind(this.updatePermissionsField,this);this.addFieldsToAnnotation=__bind(this.addFieldsToAnnotation,this);return AnnotateItPermissions.__super__.constructor.apply(this,arguments)}AnnotateItPermissions.prototype.options={showViewPermissionsCheckbox:true,showEditPermissionsCheckbox:true,groups:{world:"group:__world__",authenticated:"group:__authenticated__",consumer:"group:__consumer__"},userId:function(user){return user.userId},userString:function(user){return user.userId},userAuthorize:function(action,annotation,user){var action_field,permissions,_ref2,_ref3,_ref4,_ref5;permissions=annotation.permissions||{};action_field=permissions[action]||[];if(_ref2=this.groups.world,__indexOf.call(action_field,_ref2)>=0){return true}else if(user!=null&&user.userId!=null&&user.consumerKey!=null){if(user.userId===annotation.user&&user.consumerKey===annotation.consumer){return true}else if(_ref3=this.groups.authenticated,__indexOf.call(action_field,_ref3)>=0){return true}else if(user.consumerKey===annotation.consumer&&(_ref4=this.groups.consumer,__indexOf.call(action_field,_ref4)>=0)){return true}else if(user.consumerKey===annotation.consumer&&(_ref5=user.userId,__indexOf.call(action_field,_ref5)>=0)){return true}else if(user.consumerKey===annotation.consumer&&user.admin){return true}else{return false}}else{return false}},permissions:{read:["group:__world__"],update:[],"delete":[],admin:[]}};AnnotateItPermissions.prototype.addFieldsToAnnotation=function(annotation){if(annotation){annotation.permissions=this.options.permissions;if(this.user){annotation.user=this.user.userId;return annotation.consumer=this.user.consumerKey}}};AnnotateItPermissions.prototype.updatePermissionsField=function(action,field,annotation){var input;field=$(field).show();input=field.find("input").removeAttr("disabled");if(!this.authorize("admin",annotation)){field.hide()}if(this.user&&this.authorize(action,annotation||{},{userId:"__nonexistentuser__",consumerKey:this.user.consumerKey})){return input.attr("checked","checked")}else{return input.removeAttr("checked")}};AnnotateItPermissions.prototype.updateAnnotationPermissions=function(type,field,annotation){var dataKey;if(!annotation.permissions){annotation.permissions=this.options.permissions}dataKey=type+"-permissions";if($(field).find("input").is(":checked")){return annotation.permissions[type]=[type==="read"?this.options.groups.world:this.options.groups.consumer]}else{return annotation.permissions[type]=[]}};AnnotateItPermissions.prototype._setAuthFromToken=function(token){return this.setUser(token)};return AnnotateItPermissions}(Annotator.Plugin.Permissions);Annotator.Plugin.Filter=function(_super){__extends(Filter,_super);Filter.prototype.events={".annotator-filter-property input focus":"_onFilterFocus",".annotator-filter-property input blur":"_onFilterBlur",".annotator-filter-property input keyup":"_onFilterKeyup",".annotator-filter-previous click":"_onPreviousClick",".annotator-filter-next click":"_onNextClick",".annotator-filter-clear click":"_onClearClick"};Filter.prototype.classes={active:"annotator-filter-active",hl:{hide:"annotator-hl-filtered",active:"annotator-hl-active"}};Filter.prototype.html={element:'<div class="annotator-filter">\n <strong>'+Annotator._t("Navigate:")+'</strong>\n<span class="annotator-filter-navigation">\n <button class="annotator-filter-previous">'+Annotator._t("Previous")+'</button>\n<button class="annotator-filter-next">'+Annotator._t("Next")+"</button>\n</span>\n<strong>"+Annotator._t("Filter by:")+"</strong>\n</div>",filter:'<span class="annotator-filter-property">\n <label></label>\n <input/>\n <button class="annotator-filter-clear">'+Annotator._t("Clear")+"</button>\n</span>"};
Filter.prototype.options={appendTo:"body",filters:[],addAnnotationFilter:true,isFiltered:function(input,property){var keyword,_k,_len2,_ref2;if(!(input&&property)){return false}_ref2=input.split(/\s+/);for(_k=0,_len2=_ref2.length;_k<_len2;_k++){keyword=_ref2[_k];if(property.indexOf(keyword)===-1){return false}}return true}};function Filter(element,options){this._onPreviousClick=__bind(this._onPreviousClick,this);this._onNextClick=__bind(this._onNextClick,this);this._onFilterKeyup=__bind(this._onFilterKeyup,this);this._onFilterBlur=__bind(this._onFilterBlur,this);this._onFilterFocus=__bind(this._onFilterFocus,this);this.updateHighlights=__bind(this.updateHighlights,this);var _base;element=$(this.html.element).appendTo((options!=null?options.appendTo:void 0)||this.options.appendTo);Filter.__super__.constructor.call(this,element,options);(_base=this.options).filters||(_base.filters=[]);this.filter=$(this.html.filter);this.filters=[];this.current=0}Filter.prototype.pluginInit=function(){var filter,_k,_len2,_ref2;_ref2=this.options.filters;for(_k=0,_len2=_ref2.length;_k<_len2;_k++){filter=_ref2[_k];this.addFilter(filter)}this.updateHighlights();this._setupListeners()._insertSpacer();if(this.options.addAnnotationFilter===true){return this.addFilter({label:Annotator._t("Annotation"),property:"text"})}};Filter.prototype.destroy=function(){var currentMargin,html;Filter.__super__.destroy.apply(this,arguments);html=$("html");currentMargin=parseInt(html.css("padding-top"),10)||0;html.css("padding-top",currentMargin-this.element.outerHeight());return this.element.remove()};Filter.prototype._insertSpacer=function(){var currentMargin,html;html=$("html");currentMargin=parseInt(html.css("padding-top"),10)||0;html.css("padding-top",currentMargin+this.element.outerHeight());return this};Filter.prototype._setupListeners=function(){var event,events,_k,_len2;events=["annotationsLoaded","annotationCreated","annotationUpdated","annotationDeleted"];for(_k=0,_len2=events.length;_k<_len2;_k++){event=events[_k];this.annotator.subscribe(event,this.updateHighlights)}return this};Filter.prototype.addFilter=function(options){var f,filter;filter=$.extend({label:"",property:"",isFiltered:this.options.isFiltered},options);if(!function(){var _k,_len2,_ref2,_results;_ref2=this.filters;_results=[];for(_k=0,_len2=_ref2.length;_k<_len2;_k++){f=_ref2[_k];if(f.property===filter.property){_results.push(f)}}return _results}.call(this).length){filter.id="annotator-filter-"+filter.property;filter.annotations=[];filter.element=this.filter.clone().appendTo(this.element);filter.element.find("label").html(filter.label).attr("for",filter.id);filter.element.find("input").attr({id:filter.id,placeholder:Annotator._t("Filter by ")+filter.label+"…"});filter.element.find("button").hide();filter.element.data("filter",filter);this.filters.push(filter)}return this};Filter.prototype.updateFilter=function(filter){var annotation,annotations,input,property,_k,_len2,_ref2;filter.annotations=[];this.updateHighlights();this.resetHighlights();input=$.trim(filter.element.find("input").val());if(input){annotations=this.highlights.map(function(){return $(this).data("annotation")});_ref2=$.makeArray(annotations);for(_k=0,_len2=_ref2.length;_k<_len2;_k++){annotation=_ref2[_k];property=annotation[filter.property];if(filter.isFiltered(input,property)){filter.annotations.push(annotation)}}return this.filterHighlights()}};Filter.prototype.updateHighlights=function(){this.highlights=this.annotator.element.find(".annotator-hl:visible");return this.filtered=this.highlights.not(this.classes.hl.hide)};Filter.prototype.filterHighlights=function(){var activeFilters,annotation,annotations,filtered,highlights,index,uniques,_k,_len2,_ref2;activeFilters=$.grep(this.filters,function(filter){return!!filter.annotations.length});filtered=((_ref2=activeFilters[0])!=null?_ref2.annotations:void 0)||[];if(activeFilters.length>1){annotations=[];$.each(activeFilters,function(){return $.merge(annotations,this.annotations)});uniques=[];filtered=[];$.each(annotations,function(){if($.inArray(this,uniques)===-1){return uniques.push(this)}else{return filtered.push(this)}})}highlights=this.highlights;for(index=_k=0,_len2=filtered.length;_k<_len2;index=++_k){annotation=filtered[index];highlights=highlights.not(annotation.highlights)}highlights.addClass(this.classes.hl.hide);this.filtered=this.highlights.not(this.classes.hl.hide);return this};Filter.prototype.resetHighlights=function(){this.highlights.removeClass(this.classes.hl.hide);this.filtered=this.highlights;return this};Filter.prototype._onFilterFocus=function(event){var input;input=$(event.target);input.parent().addClass(this.classes.active);return input.next("button").show()};Filter.prototype._onFilterBlur=function(event){var input;if(!event.target.value){input=$(event.target);input.parent().removeClass(this.classes.active);return input.next("button").hide()}};Filter.prototype._onFilterKeyup=function(event){var filter;filter=$(event.target).parent().data("filter");if(filter){return this.updateFilter(filter)}};Filter.prototype._findNextHighlight=function(previous){var active,annotation,current,index,next,offset,operator,resetOffset;if(!this.highlights.length){return this}offset=previous?0:-1;resetOffset=previous?-1:0;operator=previous?"lt":"gt";active=this.highlights.not("."+this.classes.hl.hide);current=active.filter("."+this.classes.hl.active);if(!current.length){current=active.eq(offset)}annotation=current.data("annotation");index=active.index(current[0]);next=active.filter(":"+operator+"("+index+")").not(annotation.highlights).eq(resetOffset);if(!next.length){next=active.eq(resetOffset)}return this._scrollToHighlight(next.data("annotation").highlights)};Filter.prototype._onNextClick=function(event){return this._findNextHighlight()};Filter.prototype._onPreviousClick=function(event){return this._findNextHighlight(true)};Filter.prototype._scrollToHighlight=function(highlight){highlight=$(highlight);this.highlights.removeClass(this.classes.hl.active);highlight.addClass(this.classes.hl.active);return $("html, body").animate({scrollTop:highlight.offset().top-(this.element.height()+20)},150)};Filter.prototype._onClearClick=function(event){return $(event.target).prev("input").val("").keyup().blur()};return Filter}(Annotator.Plugin);Annotator.Plugin.Markdown=function(_super){__extends(Markdown,_super);Markdown.prototype.events={annotationViewerTextField:"updateTextField"};function Markdown(element,options){this.updateTextField=__bind(this.updateTextField,this);if((typeof Showdown!=="undefined"&&Showdown!==null?Showdown.converter:void 0)!=null){Markdown.__super__.constructor.apply(this,arguments);this.converter=new Showdown.converter}else{console.error(Annotator._t("To use the Markdown plugin, you must include Showdown into the page first."))}}Markdown.prototype.updateTextField=function(field,annotation){var text;text=Annotator.Util.escape(annotation.text||"");return $(field).html(this.convert(text))};Markdown.prototype.convert=function(text){return this.converter.makeHtml(text)};return Markdown}(Annotator.Plugin);Annotator.Plugin.Tags=function(_super){__extends(Tags,_super);function Tags(){this.setAnnotationTags=__bind(this.setAnnotationTags,this);this.updateField=__bind(this.updateField,this);return Tags.__super__.constructor.apply(this,arguments)}Tags.prototype.options={parseTags:function(string){var tags;string=$.trim(string);tags=[];if(string){tags=string.split(/\s+/)}return tags},stringifyTags:function(array){return array.join(" ")}};Tags.prototype.field=null;Tags.prototype.input=null;Tags.prototype.pluginInit=function(){if(!Annotator.supported()){return}this.field=this.annotator.editor.addField({label:Annotator._t("Add some tags here")+"…",load:this.updateField,submit:this.setAnnotationTags});this.annotator.viewer.addField({load:this.updateViewer});if(this.annotator.plugins.Filter){this.annotator.plugins.Filter.addFilter({label:Annotator._t("Tag"),property:"tags",isFiltered:Annotator.Plugin.Tags.filterCallback})}return this.input=$(this.field).find(":input")};Tags.prototype.parseTags=function(string){return this.options.parseTags(string)};Tags.prototype.stringifyTags=function(array){return this.options.stringifyTags(array)};Tags.prototype.updateField=function(field,annotation){var value;value="";if(annotation.tags){value=this.stringifyTags(annotation.tags)}return this.input.val(value)};Tags.prototype.setAnnotationTags=function(field,annotation){return annotation.tags=this.parseTags(this.input.val())};Tags.prototype.updateViewer=function(field,annotation){field=$(field);if(annotation.tags&&$.isArray(annotation.tags)&&annotation.tags.length){return field.addClass("annotator-tags").html(function(){var string;return string=$.map(annotation.tags,function(tag){return'<span class="annotator-tag">'+Annotator.Util.escape(tag)+"</span>"}).join(" ")})}else{return field.remove()}};return Tags}(Annotator.Plugin);Annotator.Plugin.Tags.filterCallback=function(input,tags){var keyword,keywords,matches,tag,_k,_l,_len2,_len3;if(tags==null){tags=[]}matches=0;keywords=[];if(input){keywords=input.split(/\s+/g);for(_k=0,_len2=keywords.length;_k<_len2;_k++){keyword=keywords[_k];if(tags.length){for(_l=0,_len3=tags.length;_l<_len3;_l++){tag=tags[_l];if(tag.indexOf(keyword)!==-1){matches+=1}}}}}return matches===keywords.length};Annotator.prototype.setupPlugins=function(config,options){var name,opts,pluginConfig,plugins,uri,win,_k,_len2,_results;if(config==null){config={}}if(options==null){options={}}win=Annotator.Util.getGlobal();plugins=["Unsupported","Auth","Tags","Filter","Store","AnnotateItPermissions"];if(win.Showdown){plugins.push("Markdown")}uri=win.location.href.split(/#|\?/).shift()||"";pluginConfig={Tags:{},Filter:{filters:[{label:Annotator._t("User"),property:"user"},{label:Annotator._t("Tags"),property:"tags"}]},Auth:{tokenUrl:config.tokenUrl||"http://annotateit.org/api/token"},Store:{prefix:config.storeUrl||"http://annotateit.org/api",annotationData:{uri:uri},loadFromSearch:{uri:uri}}};for(name in options){if(!__hasProp.call(options,name))continue;opts=options[name];if(__indexOf.call(plugins,name)<0){plugins.push(name)}}$.extend(true,pluginConfig,options);_results=[];for(_k=0,_len2=plugins.length;_k<_len2;_k++){name=plugins[_k];if(!(name in pluginConfig)||pluginConfig[name]){_results.push(this.addPlugin(name,pluginConfig[name]))}else{_results.push(void 0)}}return _results}}.call(this);
//////////////////////////// End of the original file //////////////////////////
return this.Annotator;
}).call(this);
<%! import json %>
<%! from student.models import anonymous_id_for_user %>
<%
if user:
params.update({'user': anonymous_id_for_user(user, None)})
%>
<div id="edx-notes-wrapper-${uid}" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">${content}</div>
</div>
<script type="text/javascript">
(function (require) {
require(['js/edxnotes/views/visibility_decorator'], function(EdxnotesVisibilityDecorator) {
var element = document.getElementById('edx-notes-wrapper-${uid}');
EdxnotesVisibilityDecorator.factory(element, ${json.dumps(params)}, ${edxnotes_visibility});
});
}).call(this, require || RequireJS.require);
</script>
......@@ -14,3 +14,6 @@ ORA_STUB_URL = os.environ.get('ora_url', 'http://localhost:8041')
# Get the URL of the comments service stub used in the test
COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567')
# Get the URL of the EdxNotes service stub used in the test
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042')
"""
Tools for creating edxnotes content fixture data.
"""
import json
import factory
import requests
from . import EDXNOTES_STUB_URL
class Range(factory.Factory):
FACTORY_FOR = dict
start = "/div[1]/p[1]"
end = "/div[1]/p[1]"
startOffset = 0
endOffset = 8
class Note(factory.Factory):
FACTORY_FOR = dict
user = "dummy-user"
usage_id = "dummy-usage-id"
course_id = "dummy-course-id"
text = "dummy note text"
quote = "dummy note quote"
ranges = [Range()]
class EdxNotesFixtureError(Exception):
"""
Error occurred while installing a edxnote fixture.
"""
pass
class EdxNotesFixture(object):
notes = []
def create_notes(self, notes_list):
self.notes = notes_list
return self
def install(self):
"""
Push the data to the stub EdxNotes service.
"""
response = requests.post(
'{}/create_notes'.format(EDXNOTES_STUB_URL),
data=json.dumps(self.notes)
)
if not response.ok:
raise EdxNotesFixtureError(
"Could not create notes {0}. Status was {1}".format(
json.dumps(self.notes), response.status_code
)
)
return self
def cleanup(self):
"""
Cleanup the stub EdxNotes service.
"""
self.notes = []
response = requests.put('{}/cleanup'.format(EDXNOTES_STUB_URL))
if not response.ok:
raise EdxNotesFixtureError(
"Could not cleanup EdxNotes service {0}. Status was {1}".format(
json.dumps(self.notes), response.status_code
)
)
return self
......@@ -61,7 +61,14 @@ class CoursewarePage(CoursePage):
(default is 0)
"""
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
# When Student Notes feature is enabled, it looks for the content inside
# `.edx-notes-wrapper-content` element (Otherwise, you will get an
# additional html related to Student Notes).
element = self.q(css='{} .edx-notes-wrapper-content'.format(self.xblock_component_selector))
if element.first:
return element.attrs('innerHTML')[index].strip()
else:
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
def tooltips_displayed(self):
"""
......
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.
......
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)
[
{
"pk": 1,
"model": "oauth2.client",
"fields": {
"name": "edx-notes",
"url": "http://example.com/",
"client_type": 1,
"redirect_uri": "http://example.com/welcome",
"user": null,
"client_id": "22a9e15e3d3b115e4d43",
"client_secret": "7969f769a1fe21ecd6cf8a1c105f250f70a27131"
}
}
]
"""
Decorators related to edXNotes.
"""
from django.conf import settings
import json
from edxnotes.helpers import (
get_endpoint,
get_id_token,
get_token_url,
generate_uid,
is_feature_enabled,
)
from edxmako.shortcuts import render_to_string
def edxnotes(cls):
"""
Decorator that makes components annotatable.
"""
original_get_html = cls.get_html
def get_html(self, *args, **kwargs):
"""
Returns raw html for the component.
"""
is_studio = getattr(self.system, "is_author_mode", False)
course = self.descriptor.runtime.modulestore.get_course(self.runtime.course_id)
# Must be disabled:
# - in Studio;
# - when Harvard Annotation Tool is enabled for the course;
# - when the feature flag or `edxnotes` setting of the course is set to False.
if is_studio or not is_feature_enabled(course):
return original_get_html(self, *args, **kwargs)
else:
return render_to_string("edxnotes_wrapper.html", {
"content": original_get_html(self, *args, **kwargs),
"uid": generate_uid(),
"edxnotes_visibility": json.dumps(
getattr(self, 'edxnotes_visibility', course.edxnotes_visibility)
),
"params": {
# Use camelCase to name keys.
"usageId": unicode(self.scope_ids.usage_id).encode("utf-8"),
"courseId": unicode(self.runtime.course_id).encode("utf-8"),
"token": get_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)),
"tokenUrl": get_token_url(self.runtime.course_id),
"endpoint": get_endpoint(),
"debug": settings.DEBUG,
},
})
cls.get_html = get_html
return cls
"""
Exceptions related to EdxNotes.
"""
class EdxNotesParseError(Exception):
"""
An exception that is raised whenever we have issues with data parsing.
"""
pass
class EdxNotesServiceUnavailable(Exception):
"""
An exception that is raised whenever EdxNotes service is unavailable.
"""
pass
"""
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))
"""
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 &lt;script&gt;alert('test')&lt;/script&gt;",
u"text": u'text "&lt;&gt;&amp;\'',
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)
"""
URLs for EdxNotes.
"""
from django.conf.urls import patterns, url
# Additionally, we include login URLs for the browseable API.
urlpatterns = patterns(
"edxnotes.views",
url(r"^/$", "edxnotes", name="edxnotes"),
url(r"^/search/$", "search_notes", name="search_notes"),
url(r"^/token/$", "get_token", name="get_token"),
url(r"^/visibility/$", "edxnotes_visibility", name="edxnotes_visibility"),
)
"""
Views related to EdxNotes.
"""
import json
import logging
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.conf import settings
from django.core.urlresolvers import reverse
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
from util.json_request import JsonResponse, JsonResponseBadRequest
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
from edxnotes.helpers import (
get_notes,
get_id_token,
is_feature_enabled,
search,
get_course_position,
)
log = logging.getLogger(__name__)
@login_required
def edxnotes(request, course_id):
"""
Displays the EdxNotes page.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
if not is_feature_enabled(course):
raise Http404
try:
notes = get_notes(request.user, course)
except EdxNotesServiceUnavailable:
raise Http404
context = {
"course": course,
"search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}),
"notes": notes,
"debug": json.dumps(settings.DEBUG),
'position': None,
}
if not notes:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id, request.user, course, depth=2
)
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
position = get_course_position(course_module)
if position:
context.update({
'position': position,
})
return render_to_response("edxnotes/edxnotes.html", context)
@login_required
def search_notes(request, course_id):
"""
Handles search requests.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
if not is_feature_enabled(course):
raise Http404
if "text" not in request.GET:
return HttpResponseBadRequest()
query_string = request.GET["text"]
try:
search_results = search(request.user, course, query_string)
except (EdxNotesParseError, EdxNotesServiceUnavailable) as err:
return JsonResponseBadRequest({"error": err.message}, status=500)
return HttpResponse(search_results)
# pylint: disable=unused-argument
@login_required
def get_token(request, course_id):
"""
Get JWT ID-Token, in case you need new one.
"""
return HttpResponse(get_id_token(request.user), content_type='text/plain')
@login_required
def edxnotes_visibility(request, course_id):
"""
Handle ajax call from "Show notes" checkbox.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
field_data_cache = FieldDataCache([course], course_key, request.user)
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
if not is_feature_enabled(course):
raise Http404
try:
visibility = json.loads(request.body)["visibility"]
course_module.edxnotes_visibility = visibility
course_module.save()
return JsonResponse(status=200)
except (ValueError, KeyError):
log.warning(
"Could not decode request body as JSON and find a boolean visibility field: '%s'", request.body
)
return JsonResponseBadRequest()
......@@ -66,6 +66,9 @@ XQUEUE_INTERFACE['url'] = 'http://localhost:8040'
# Configure the LMS to use our stub ORA implementation
OPEN_ENDED_GRADING_INTERFACE['url'] = 'http://localhost:8041/'
# Configure the LMS to use our stub EdxNotes implementation
EDXNOTES_INTERFACE['url'] = 'http://localhost:8042/api/v1'
# Enable django-pipeline and staticfiles
STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath()
......
......@@ -311,6 +311,9 @@ FEATURES = {
# Show the mobile app links in the footer
'ENABLE_FOOTER_MOBILE_APP_LINKS': False,
# let students save and manage their annotations
'ENABLE_EDXNOTES': False,
}
# Ignore static asset files on import which match this pattern
......@@ -911,6 +914,14 @@ MOCK_PEER_GRADING = False
# Used for testing, debugging staff grading
MOCK_STAFF_GRADING = False
################################# EdxNotes config #########################
# Configure the LMS to use our stub EdxNotes implementation
EDXNOTES_INTERFACE = {
'url': 'http://localhost:8120/api/v1',
}
################################# Jasmine ##################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
......@@ -1174,6 +1185,7 @@ PIPELINE_CSS = {
'js/vendor/CodeMirror/codemirror.css',
'css/vendor/jquery.treeview.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/edxnotes/annotator.min.css',
],
'output_filename': 'css/lms-style-course-vendor.css',
},
......@@ -1229,6 +1241,7 @@ PIPELINE_JS = {
'js/src/accessibility_tools.js',
'js/src/ie_shim.js',
'js/src/string_utils.js',
'js/src/logger.js',
],
'output_filename': 'js/lms-application.js',
},
......@@ -1520,6 +1533,8 @@ INSTALLED_APPS = (
'django_comment_common',
'notes',
'edxnotes',
# Splash screen
'splash',
......@@ -1949,3 +1964,7 @@ COURSE_ABOUT_VISIBILITY_PERMISSION = 'see_exists'
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
# for Student Notes we would like to avoid too frequent token refreshes (default is 30 seconds)
if FEATURES['ENABLE_EDXNOTES']:
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
......@@ -425,3 +425,6 @@ MONGODB_LOG = {
'password': '',
'db': 'xlog',
}
# Enable EdxNotes for tests.
FEATURES['ENABLE_EDXNOTES'] = True
......@@ -72,7 +72,7 @@ class @Calculator
.attr
'title': text
'aria-expanded': isExpanded
.text text
.find('.utility-control-label').text text
$calc.toggleClass 'closed'
......
......@@ -2,7 +2,6 @@ class @Courseware
@prefix: ''
constructor: ->
Courseware.prefix = $("meta[name='path_prefix']").attr('content')
new Navigation
Logger.bind()
@render()
......
AjaxPrefix.addAjaxPrefix(jQuery, -> Courseware.prefix)
AjaxPrefix.addAjaxPrefix(jQuery, -> $("meta[name='path_prefix']").attr('content'))
$ ->
$.ajaxSetup
......
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/note'
], function (Backbone, NoteModel) {
var NotesCollection = Backbone.Collection.extend({
model: NoteModel,
/**
* Returns course structure from the list of notes.
* @return {Object}
*/
getCourseStructure: (function () {
var courseStructure = null;
return function () {
var chapters = {},
sections = {},
units = {};
if (!courseStructure) {
this.each(function (note) {
var chapter = note.get('chapter'),
section = note.get('section'),
unit = note.get('unit');
chapters[chapter.location] = chapter;
sections[section.location] = section;
units[unit.location] = units[unit.location] || [];
units[unit.location].push(note);
});
courseStructure = {
chapters: _.sortBy(_.toArray(chapters), function (c) {return c.index;}),
sections: sections,
units: units
};
}
return courseStructure;
};
}())
});
return NotesCollection;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/tab'
], function (Backbone, TabModel) {
var TabsCollection = Backbone.Collection.extend({
model: TabModel
});
return TabsCollection;
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone', 'underscore.string'], function (Backbone) {
var NoteModel = Backbone.Model.extend({
defaults: {
'id': null,
'created': '',
'updated': '',
'user': '',
'usage_id': '',
'course_id': '',
'text': '',
'quote': '',
'ranges': [],
'unit': {
'display_name': '',
'url': '',
'location': ''
},
'section': {
'display_name': '',
'location': '',
'children': []
},
'chapter': {
'display_name': '',
'location': '',
'index': 0,
'children': []
},
// Flag indicating current state of the note: expanded or collapsed.
'is_expanded': false,
// Flag indicating whether `More` link should be shown.
'show_link': false
},
textSize: 300,
initialize: function () {
if (this.get('quote').length > this.textSize) {
this.set('show_link', true);
}
},
getNoteText: function () {
var message = this.get('quote');
if (!this.get('is_expanded') && this.get('show_link')) {
message = _.str.prune(message, this.textSize);
}
return message;
}
});
return NoteModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['backbone'], function (Backbone) {
var TabModel = Backbone.Model.extend({
defaults: {
'identifier': '',
'name': '',
'icon': '',
'is_active': false,
'is_closable': false
},
activate: function () {
this.collection.each(_.bind(function(model) {
// Inactivate all other models.
if (model !== this) {
model.inactivate();
}
}, this));
this.set('is_active', true);
},
inactivate: function () {
this.set('is_active', false);
},
isActive: function () {
return this.get('is_active');
}
});
return TabModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'annotator', 'underscore.string'
], function (_, Annotator) {
/**
* Modifies Annotator.Plugin.Store.annotationCreated to make it trigger a new
* event `annotationFullyCreated` when annotation is fully created and has
* an id.
*/
Annotator.Plugin.Store.prototype.annotationCreated = _.compose(
function (jqXhr) {
return jqXhr.done(_.bind(function (annotation) {
if (annotation && annotation.id){
this.publish('annotationFullyCreated', annotation);
}
}, this));
},
Annotator.Plugin.Store.prototype.annotationCreated
);
/**
* Adds the Events Plugin which emits events to capture user intent.
* Emits the following events:
* - 'edx.course.student_notes.viewed'
* [(user, note ID, datetime), (user, note ID, datetime)] - a list of notes.
* - 'edx.course.student_notes.added'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.edited'
* (user, note ID, old note text, new note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.deleted'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
**/
Annotator.Plugin.Events = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
_.extend(Annotator.Plugin.Events.prototype, new Annotator.Plugin(), {
pluginInit: function () {
_.bindAll(this,
'annotationViewerShown', 'annotationFullyCreated', 'annotationEditorShown',
'annotationEditorHidden', 'annotationUpdated', 'annotationDeleted'
);
this.annotator
.subscribe('annotationViewerShown', this.annotationViewerShown)
.subscribe('annotationFullyCreated', this.annotationFullyCreated)
.subscribe('annotationEditorShown', this.annotationEditorShown)
.subscribe('annotationEditorHidden', this.annotationEditorHidden)
.subscribe('annotationUpdated', this.annotationUpdated)
.subscribe('annotationDeleted', this.annotationDeleted);
},
destroy: function () {
this.annotator
.unsubscribe('annotationViewerShown', this.annotationViewerShown)
.unsubscribe('annotationFullyCreated', this.annotationFullyCreated)
.unsubscribe('annotationEditorShown', this.annotationEditorShown)
.unsubscribe('annotationEditorHidden', this.annotationEditorHidden)
.unsubscribe('annotationUpdated', this.annotationUpdated)
.unsubscribe('annotationDeleted', this.annotationDeleted);
},
annotationViewerShown: function (viewer, annotations) {
// Emits an event only when the annotation already exists on the
// server. Otherwise, `annotation.id` is `undefined`.
var data;
annotations = _.reject(annotations, this.isNew);
data = {
'notes': _.map(annotations, function (annotation) {
return {'note_id': annotation.id};
})
};
if (data.notes.length) {
this.log('edx.course.student_notes.viewed', data);
}
},
annotationFullyCreated: function (annotation) {
var data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.added', data);
},
annotationEditorShown: function (editor, annotation) {
this.oldNoteText = annotation.text || '';
},
annotationEditorHidden: function () {
this.oldNoteText = null;
},
annotationUpdated: function (annotation) {
var data;
if (!this.isNew(annotation)) {
data = _.extend(
this.getDefaultData(annotation),
this.getText('old_note_text', this.oldNoteText)
);
this.log('edx.course.student_notes.edited', data);
}
},
annotationDeleted: function (annotation) {
var data;
// Emits an event only when the annotation already exists on the
// server.
if (!this.isNew(annotation)) {
data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.deleted', data);
}
},
getDefaultData: function (annotation) {
return _.extend(
{
'note_id': annotation.id,
'component_usage_id': annotation.usage_id
},
this.getText('note_text', annotation.text),
this.getText('highlighted_content', annotation.quote)
);
},
getText: function (fieldName, text) {
var info = {},
truncated = false,
limit = this.options.stringLimit;
if (_.isNumber(limit) && _.isString(text) && text.length > limit) {
text = String(text).slice(0, limit);
truncated = true;
}
info[fieldName] = text;
info[fieldName + '_truncated'] = truncated;
return info;
},
/**
* If the model does not yet have an id, it is considered to be new.
* @return {Boolean}
*/
isNew: function (annotation) {
return !_.has(annotation, 'id');
},
log: function (eventName, data) {
this.annotator.logger.emit(eventName, data);
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
/**
* Adds the Scroller Plugin which scrolls to a note with a certain id and
* opens it.
**/
Annotator.Plugin.Scroller = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
$.extend(Annotator.Plugin.Scroller.prototype, new Annotator.Plugin(), {
getIdFromLocationHash: function() {
return window.location.hash.substr(1);
},
pluginInit: function () {
_.bindAll(this, 'onNotesLoaded');
// If the page URL contains a hash, we could be coming from a click
// on an anchor in the notes page. In that case, the hash is the id
// of the note that has to be scrolled to and opened.
if (this.getIdFromLocationHash()) {
this.annotator.subscribe('annotationsLoaded', this.onNotesLoaded);
}
},
destroy: function () {
this.annotator.unsubscribe('annotationsLoaded', this.onNotesLoaded);
},
onNotesLoaded: function (notes) {
var hash = this.getIdFromLocationHash();
this.annotator.logger.log('Scroller', {
'notes:': notes,
'hash': hash
});
_.each(notes, function (note) {
var highlight, offset;
if (note.id === hash && note.highlights.length) {
// Clear the page URL hash, it won't be needed once we've
// scrolled and opened the relevant note. And it would
// unnecessarily repeat the steps below if we come from
// another sequential.
window.location.hash = '';
highlight = $(note.highlights[0]);
offset = highlight.position();
// Open the note
this.annotator.showFrozenViewer([note], {
top: offset.top + 0.5 * highlight.height(),
left: offset.left + 0.5 * highlight.width()
});
// Scroll to highlight
this.scrollIntoView(highlight);
}
}, this);
},
scrollIntoView: function (highlight) {
highlight.focus();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['underscore', 'logger'], function (_, Logger) {
var loggers = [],
NotesLogger, now, destroyLogger;
now = function () {
if (performance && performance.now) {
return performance.now();
} else if (Date.now) {
return Date.now();
} else {
return (new Date()).getTime();
}
};
/**
* Removes a reference on the logger from `loggers`.
* @param {Object} logger An instance of Logger.
*/
destroyLogger = function (logger) {
var index = loggers.length,
removedLogger;
while(index--) {
if (loggers[index].id === logger.id) {
removedLogger = loggers.splice(index, 1)[0];
removedLogger.historyStorage = [];
removedLogger.timeStorage = {};
break;
}
}
};
/**
* NotesLogger constructor.
* @constructor
* @param {String} id Id of the logger.
* @param {Boolean|Number} mode Outputs messages to the Web Console if true.
*/
NotesLogger = function (id, mode) {
this.id = id;
this.historyStorage = [];
this.timeStorage = {};
// 0 - silent;
// 1 - show logs;
this.logLevel = mode;
};
/**
* Outputs a message with appropriate type to the Web Console and
* store it in the history.
* @param {String} logType The type of the log message.
* @param {Arguments} args Information that will be stored.
*/
NotesLogger.prototype._log = function (logType, args) {
if (!this.logLevel) {
return false;
}
this.updateHistory.apply(this, arguments);
// Adds ID at the first place
Array.prototype.unshift.call(args, this.id);
if (console && console[logType]) {
if (console[logType].apply){
console[logType].apply(console, args);
} else { // Do this for IE
console[logType](args.join(' '));
}
}
};
/**
* Outputs a message to the Web Console and store it in the history.
*/
NotesLogger.prototype.log = function () {
this._log('log', arguments);
};
/**
* Outputs an error message to the Web Console and store it in the history.
*/
NotesLogger.prototype.error = function () {
this._log('error', arguments);
};
/**
* Adds information to the history.
*/
NotesLogger.prototype.updateHistory = function () {
this.historyStorage.push(arguments);
};
/**
* Returns the history for the logger.
* @return {Array}
*/
NotesLogger.prototype.getHistory = function () {
return this.historyStorage;
};
/**
* Starts a timer you can use to track how long an operation takes.
* @param {String} label Timer name.
*/
NotesLogger.prototype.time = function (label) {
this.timeStorage[label] = now();
};
/**
* Stops a timer that was previously started by calling NotesLogger.prototype.time().
* @param {String} label Timer name.
*/
NotesLogger.prototype.timeEnd = function (label) {
if (!this.timeStorage[label]) {
return null;
}
this._log('log', [label, now() - this.timeStorage[label], 'ms']);
delete this.timeStorage[label];
};
NotesLogger.prototype.destroy = function () {
destroyLogger(this);
};
/**
* Emits the event.
* @param {String} eventName The name of the event.
* @param {*} data Information about the event.
* @param {Number} timeout Optional timeout for the ajax request in ms.
*/
NotesLogger.prototype.emit = function (eventName, data, timeout) {
var args = [eventName, data];
this.log(eventName, data);
if (timeout) {
args.push(null, {'timeout': timeout});
}
return Logger.log.apply(Logger, args);
};
return {
getLogger: function (id, mode) {
var logger = new NotesLogger(id, mode);
loggers.push(logger);
return logger;
},
destroyLogger: destroyLogger
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore'], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @return The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = '#' + name + '-tpl',
templateText = $(templateSelector).text();
if (!templateText) {
console.error('Failed to load ' + name + ' template');
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var NoteSectionView, NoteGroupView;
NoteSectionView = Backbone.View.extend({
tagName: 'section',
className: 'note-section',
id: function () {
return 'note-section-' + _.uniqueId();
},
template: _.template('<h4 class="course-subtitle"><%- sectionName %></h4>'),
render: function () {
this.$el.prepend(this.template({
sectionName: this.options.section.display_name
}));
return this;
},
addChild: function (child) {
this.$el.append(child);
}
});
NoteGroupView = Backbone.View.extend({
tagName: 'section',
className: 'note-group',
id: function () {
return 'note-group-' + _.uniqueId();
},
template: _.template('<h3 class="course-title"><%- chapterName %></h3>'),
initialize: function () {
this.children = [];
},
render: function () {
var container = document.createDocumentFragment();
this.$el.html(this.template({
chapterName: this.options.chapter.display_name || ''
}));
_.each(this.children, function (section) {
container.appendChild(section.render().el);
});
this.$el.append(container);
return this;
},
addChild: function (sectionInfo) {
var section = new NoteSectionView({section: sectionInfo});
this.children.push(section);
return section;
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteGroupView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore','backbone', 'js/edxnotes/utils/template',
'js/edxnotes/utils/logger'
], function ($, _, Backbone, templateUtils, NotesLogger) {
var NoteItemView = Backbone.View.extend({
tagName: 'article',
className: 'note',
id: function () {
return 'note-' + _.uniqueId();
},
events: {
'click .note-excerpt-more-link': 'moreHandler',
'click .reference-unit-link': 'unitLinkHandler',
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('note-item');
this.logger = NotesLogger.getLogger('note_item', options.debug);
this.listenTo(this.model, 'change:is_expanded', this.render);
},
render: function () {
var context = this.getContext();
this.$el.html(this.template(context));
return this;
},
getContext: function () {
return $.extend({
message: this.model.getNoteText()
}, this.model.toJSON());
},
toggleNote: function () {
var value = !this.model.get('is_expanded');
this.model.set('is_expanded', value);
},
moreHandler: function (event) {
event.preventDefault();
this.toggleNote();
},
unitLinkHandler: function (event) {
var REQUEST_TIMEOUT = 2000;
event.preventDefault();
this.logger.emit('edx.student_notes.used_unit_link', {
'note_id': this.model.get('id'),
'component_usage_id': this.model.get('usage_id')
}, REQUEST_TIMEOUT).always(_.bind(function () {
this.redirectTo(event.target.href);
}, this));
},
redirectTo: function (uri) {
window.location = uri;
},
remove: function () {
this.logger.destroy();
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteItemView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger',
'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller',
'js/edxnotes/plugins/events'
], function ($, _, Annotator, NotesLogger) {
var plugins = ['Auth', 'Store', 'Scroller', 'Events'],
getOptions, setupPlugins, updateHeaders, getAnnotator;
/**
* Returns options for the annotator.
* @param {jQuery Element} The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} Options.
**/
getOptions = function (element, params) {
var defaultParams = {
user: params.user,
usage_id: params.usageId,
course_id: params.courseId
},
prefix = params.endpoint.replace(/(.+)\/$/, '$1');
return {
auth: {
token: params.token,
tokenUrl: params.tokenUrl
},
events: {
stringLimit: 300
},
store: {
prefix: prefix,
annotationData: defaultParams,
loadFromSearch: defaultParams,
urls: {
create: '/annotations/',
read: '/annotations/:id/',
update: '/annotations/:id/',
destroy: '/annotations/:id/',
search: '/search/'
}
}
};
};
/**
* Setups plugins for the annotator.
* @param {Object} annotator An instance of the annotator.
* @param {Array} plugins A list of plugins for the annotator.
* @param {Object} options An options for the annotator.
**/
setupPlugins = function (annotator, plugins, options) {
_.each(plugins, function(plugin) {
var settings = options[plugin.toLowerCase()];
annotator.addPlugin(plugin, settings);
}, this);
};
/**
* Factory method that returns Annotator.js instantiates.
* @param {DOM Element} element The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} An instance of Annotator.js.
**/
getAnnotator = function (element, params) {
var el = $(element),
options = getOptions(el, params),
logger = NotesLogger.getLogger(element.id, params.debug),
annotator;
annotator = el.annotator(options).data('annotator');
setupPlugins(annotator, plugins, options);
annotator.logger = logger;
logger.log({'element': element, 'options': options});
return annotator;
};
return {
factory: getAnnotator
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs_list',
'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/course_structure',
'js/edxnotes/views/tabs/search_results'
], function (
Backbone, TabsCollection, TabsListView, RecentActivityView, CourseStructureView,
SearchResultsView
) {
var NotesPageView = Backbone.View.extend({
initialize: function (options) {
this.options = options;
this.tabsCollection = new TabsCollection();
this.recentActivityView = new RecentActivityView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.searchResultsView = new SearchResultsView({
el: this.el,
tabsCollection: this.tabsCollection,
debug: this.options.debug,
createTabOnInitialization: false
});
this.tabsView = new TabsListView({collection: this.tabsCollection});
this.$('.tab-list')
.append(this.tabsView.render().$el)
.removeClass('is-hidden');
}
});
return NotesPageView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'js/edxnotes/collections/notes', 'js/edxnotes/views/notes_page'
], function ($, NotesCollection, NotesPageView) {
/**
* Factory method for the Notes page.
* @param {Object} params Params for the Notes page.
* @param {Array} params.notesList A list of note models.
* @param {Boolean} params.debugMode Enable the flag to see debug information.
* @param {String} params.endpoint The endpoint of the store.
* @return {Object} An instance of NotesPageView.
*/
return function (params) {
var collection = new NotesCollection(params.notesList);
return new NotesPageView({
el: $('.wrapper-student-notes').get(0),
collection: collection,
debug: params.debugMode,
endpoint: params.endpoint
});
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/utils/logger',
'js/edxnotes/collections/notes'
], function ($, _, Backbone, gettext, NotesLogger, NotesCollection) {
var SearchBoxView = Backbone.View.extend({
events: {
'submit': 'submitHandler'
},
errorMessage: gettext('An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.'),
emptyFieldMessage: (function () {
var message = gettext('Please enter a term in the %(anchor_start)s search field%(anchor_end)s.');
return interpolate(message, {
'anchor_start': '<a href="#search-notes-input">',
'anchor_end': '</a>'
}, true);
} ()),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError', 'onComplete');
this.options = _.defaults(options || {}, {
beforeSearchStart: function () {},
search: function () {},
error: function () {},
complete: function () {}
});
this.logger = NotesLogger.getLogger('search_box', this.options.debug);
this.$el.removeClass('is-hidden');
this.isDisabled = false;
this.logger.log('initialized');
},
submitHandler: function (event) {
event.preventDefault();
this.search();
},
/**
* Prepares server response to appropriate structure.
* @param {Object} data The response form the server.
* @return {Array}
*/
prepareData: function (data) {
var collection;
if (!(data && _.has(data, 'total') && _.has(data, 'rows'))) {
this.logger.log('Wrong data', data, this.searchQuery);
return null;
}
collection = new NotesCollection(data.rows);
return [collection, data.total, this.searchQuery];
},
/**
* Returns search text.
* @return {String}
*/
getSearchQuery: function () {
return this.$el.find('#search-notes-input').val();
},
/**
* Starts search if form is not disabled.
* @return {Boolean} Indicates if search is started or not.
*/
search: function () {
if (this.isDisabled) {
return false;
}
this.searchQuery = this.getSearchQuery();
if (!this.validateField(this.searchQuery)) {
return false;
}
this.options.beforeSearchStart(this.searchQuery);
this.disableForm();
this.sendRequest(this.searchQuery)
.done(this.onSuccess)
.fail(this.onError)
.complete(this.onComplete);
return true;
},
validateField: function (searchQuery) {
if (!($.trim(searchQuery))) {
this.options.error(this.emptyFieldMessage, searchQuery);
return false;
}
return true;
},
onSuccess: function (data) {
var args = this.prepareData(data);
if (args) {
this.options.search.apply(this, args);
this.logger.emit('edx.student_notes.searched', {
'number_of_results': args[1],
'search_string': args[2]
});
} else {
this.options.error(this.errorMessage, this.searchQuery);
}
},
onError:function (jXHR) {
var searchQuery = this.getSearchQuery(),
message;
if (jXHR.responseText) {
try {
message = $.parseJSON(jXHR.responseText).error;
} catch (error) { }
}
this.options.error(message || this.errorMessage, searchQuery);
this.logger.log('Response fails', jXHR.responseText);
},
onComplete: function () {
this.enableForm();
this.options.complete(this.searchQuery);
},
enableForm: function () {
this.isDisabled = false;
this.$el.removeClass('is-looking');
this.$('button[type=submit]').removeClass('is-disabled');
},
disableForm: function () {
this.isDisabled = true;
this.$el.addClass('is-looking');
this.$('button[type=submit]').addClass('is-disabled');
},
/**
* Sends a request with appropriate configurations.
* @param {String} text Search query.
* @return {jQuery.Deferred}
*/
sendRequest: function (text) {
var settings = {
url: this.el.action,
type: this.el.method,
dataType: 'json',
data: {text: text}
};
this.logger.log(settings);
return $.ajax(settings);
}
});
return SearchBoxView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
var _t = Annotator._t;
/**
* We currently run JQuery 1.7.2 in Jasmine tests and LMS.
* AnnotatorJS 1.2.9. uses two calls to addBack (in the two functions
* 'isAnnotator' and 'onHighlightMouseover') which was only defined in
* JQuery 1.8.0. In LMS, it works without throwing an error because
* JQuery.UI 1.10.0 adds support to jQuery<1.8 by augmenting '$.fn' with
* that missing function. It is not the case for all Jasmine unit tests,
* so we add it here if necessary.
**/
if (!$.fn.addBack) {
$.fn.addBack = function (selector) {
return this.add(
selector === null ? this.prevObject : this.prevObject.filter(selector)
);
};
}
/**
* The original _setupDynamicStyle uses a very expensive call to
* Util.maxZIndex(...) that sets the z-index of .annotator-adder,
* .annotator-outer, .annotator-notice, .annotator-filter. We set these
* values in annotator.min.css instead and do nothing here.
*/
Annotator.prototype._setupDynamicStyle = function() { };
Annotator.frozenSrc = null;
/**
* Modifies Annotator.Plugin.Auth.haveValidToken to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
return (
this._unsafeToken &&
this._unsafeToken.sub &&
this._unsafeToken.exp &&
this._unsafeToken.iat &&
this.timeToExpiry() > 0
);
};
/**
* Modifies Annotator.Plugin.Auth.timeToExpiry to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
var now = new Date().getTime() / 1000,
expiry = this._unsafeToken.exp,
timeToExpiry = expiry - now;
return (timeToExpiry > 0) ? timeToExpiry : 0;
};
/**
* Modifies Annotator.highlightRange to add a "tabindex=0" attribute
* to the <span class="annotator-hl"> markup that encloses the note.
* These are then focusable via the TAB key.
**/
Annotator.prototype.highlightRange = _.compose(
function (results) {
$('.annotator-hl', this.wrapper).attr('tabindex', 0);
return results;
},
Annotator.prototype.highlightRange
);
/**
* Modifies Annotator.destroy to unbind click.edxnotes:freeze from the
* document and reset isFrozen to default value, false.
**/
Annotator.prototype.destroy = _.compose(
Annotator.prototype.destroy,
function () {
// We are destroying the instance that has the popup visible, revert to default,
// unfreeze all instances and set their isFrozen to false
if (this === Annotator.frozenSrc) {
this.unfreezeAll();
} else {
// Unfreeze only this instance and unbound associated 'click.edxnotes:freeze' handler
$(document).off('click.edxnotes:freeze' + this.uid);
this.isFrozen = false;
}
if (this.logger && this.logger.destroy) {
this.logger.destroy();
}
// Unbind onNoteClick from click
this.viewer.element.off('click', this.onNoteClick);
}
);
/**
* Modifies Annotator.Viewer.html.item template to add an i18n for the
* buttons.
**/
Annotator.Viewer.prototype.html.item = [
'<li class="annotator-annotation annotator-item">',
'<span class="annotator-controls">',
'<a href="#" title="', _t('View as webpage'), '" class="annotator-link">',
_t('View as webpage'),
'</a>',
'<button title="', _t('Edit'), '" class="annotator-edit">',
_t('Edit'),
'</button>',
'<button title="', _t('Delete'), '" class="annotator-delete">',
_t('Delete'),
'</button>',
'</span>',
'</li>'
].join('');
/**
* Modifies Annotator._setupViewer to add a "click" event on viewer.
**/
Annotator.prototype._setupViewer = _.compose(
function () {
this.viewer.element.on('click', _.bind(this.onNoteClick, this));
return this;
},
Annotator.prototype._setupViewer
);
$.extend(true, Annotator.prototype, {
events: {
'.annotator-hl click': 'onHighlightClick',
'.annotator-viewer click': 'onNoteClick'
},
isFrozen: false,
uid: _.uniqueId(),
onHighlightClick: function (event) {
Annotator.Util.preventEventDefault(event);
if (!this.isFrozen) {
event.stopPropagation();
this.onHighlightMouseover.call(this, event);
}
Annotator.frozenSrc = this;
this.freezeAll();
},
onNoteClick: function (event) {
event.stopPropagation();
Annotator.Util.preventEventDefault(event);
if (!$(event.target).is('.annotator-delete')) {
Annotator.frozenSrc = this;
this.freezeAll();
}
},
freeze: function () {
if (!this.isFrozen) {
// Remove default events
this.removeEvents();
this.viewer.element.unbind('mouseover mouseout');
this.uid = _.uniqueId();
$(document).on('click.edxnotes:freeze' + this.uid, _.bind(this.unfreeze, this));
this.isFrozen = true;
}
},
unfreeze: function () {
if (this.isFrozen) {
// Add default events
this.addEvents();
this.viewer.element.bind({
'mouseover': this.clearViewerHideTimer,
'mouseout': this.startViewerHideTimer
});
this.viewer.hide();
$(document).off('click.edxnotes:freeze'+this.uid);
this.isFrozen = false;
Annotator.frozenSrc = null;
}
},
freezeAll: function () {
_.invoke(Annotator._instances, 'freeze');
},
unfreezeAll: function () {
_.invoke(Annotator._instances, 'unfreeze');
},
showFrozenViewer: function (annotations, location) {
this.showViewer(annotations, location);
this.freezeAll();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'],
function (gettext, _, Backbone, templateUtils) {
var TabItemView = Backbone.View.extend({
tagName: 'li',
className: 'tab',
activeClassName: 'is-active',
events: {
'click': 'selectHandler',
'click a': function (event) { event.preventDefault(); },
'click .action-close': 'closeHandler'
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('tab-item');
this.$el.attr('id', this.model.get('identifier'));
this.listenTo(this.model, {
'change:is_active': function (model, value) {
this.$el.toggleClass(this.activeClassName, value);
if (value) {
this.$('.tab-label').prepend($('<span />', {
'class': 'tab-aria-label sr',
'text': gettext('Current tab')
}));
} else {
this.$('.tab-aria-label').remove();
}
},
'destroy': this.remove
});
},
render: function () {
var html = this.template(this.model.toJSON());
this.$el.html(html);
return this;
},
selectHandler: function (event) {
event.preventDefault();
if (!this.model.isActive()) {
this.model.activate();
}
},
closeHandler: function (event) {
event.preventDefault();
event.stopPropagation();
this.model.destroy();
}
});
return TabItemView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item'],
function (gettext, _, Backbone, NoteItemView) {
var TabPanelView = Backbone.View.extend({
tagName: 'section',
className: 'tab-panel',
title: '',
titleTemplate: _.template('<h2 class="sr"><%- text %></h2>'),
attributes: {
'tabindex': -1
},
initialize: function () {
this.children = [];
},
render: function () {
this.$el.html(this.getTitle());
this.renderContent();
return this;
},
renderContent: function () {
return this;
},
getNotes: function (collection) {
var container = document.createDocumentFragment(),
notes = _.map(collection, function (model) {
var note = new NoteItemView({model: model});
container.appendChild(note.render().el);
return note;
});
this.children = this.children.concat(notes);
return container;
},
getTitle: function () {
return this.title ? this.titleTemplate({text: gettext(this.title)}) : '';
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return TabPanelView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/models/tab'
], function (_, Backbone, TabModel) {
var TabView = Backbone.View.extend({
PanelConstructor: null,
tabInfo: {
name: '',
class_name: ''
},
initialize: function (options) {
_.bindAll(this, 'showLoadingIndicator', 'hideLoadingIndicator');
this.options = _.defaults(options || {}, {
createTabOnInitialization: true
});
if (this.options.createTabOnInitialization) {
this.createTab();
}
},
/**
* Creates a tab for the view.
*/
createTab: function () {
this.tabModel = new TabModel(this.tabInfo);
this.options.tabsCollection.add(this.tabModel);
this.listenTo(this.tabModel, {
'change:is_active': function (model, value) {
if (value) {
this.render();
} else {
this.destroySubView();
}
},
'destroy': function () {
this.destroySubView();
this.tabModel = null;
this.onClose();
}
});
},
/**
* Renders content for the view.
*/
render: function () {
this.hideErrorMessage().showLoadingIndicator();
// If the view is already rendered, destroy it.
this.destroySubView();
this.renderContent().always(this.hideLoadingIndicator);
return this;
},
renderContent: function () {
this.contentView = this.getSubView();
this.$('.wrapper-tabs').append(this.contentView.render().$el);
return $.Deferred().resolve().promise();
},
getSubView: function () {
var collection = this.getCollection();
return new this.PanelConstructor({collection: collection});
},
destroySubView: function () {
if (this.contentView) {
this.contentView.remove();
this.contentView = null;
}
},
/**
* Returns collection for the view.
* @return {Backbone.Collection}
*/
getCollection: function () {
return this.collection;
},
/**
* Callback that is called on closing the tab.
*/
onClose: function () { },
/**
* Returns the page's loading indicator.
*/
getLoadingIndicator: function() {
return this.$('.ui-loading');
},
/**
* Shows the page's loading indicator.
*/
showLoadingIndicator: function() {
this.getLoadingIndicator().removeClass('is-hidden');
return this;
},
/**
* Hides the page's loading indicator.
*/
hideLoadingIndicator: function() {
this.getLoadingIndicator().addClass('is-hidden');
return this;
},
/**
* Shows error message.
*/
showErrorMessage: function (message) {
this.$('.wrapper-msg')
.removeClass('is-hidden')
.find('.msg-content .copy').html(message);
return this;
},
/**
* Hides error message.
*/
hideErrorMessage: function () {
this.$('.wrapper-msg')
.addClass('is-hidden')
.find('.msg-content .copy').html('');
return this;
}
});
return TabView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'js/edxnotes/views/tab_view'
], function (gettext, NoteGroupView, TabPanelView, TabView) {
var CourseStructureView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'structure-panel',
title: 'Location in Course',
renderContent: function () {
var courseStructure = this.collection.getCourseStructure(),
container = document.createDocumentFragment();
_.each(courseStructure.chapters, function (chapterInfo) {
var group = this.getGroup(chapterInfo);
_.each(chapterInfo.children, function (location) {
var sectionInfo = courseStructure.sections[location],
section;
if (sectionInfo) {
section = group.addChild(sectionInfo);
_.each(sectionInfo.children, function (location) {
var notes = courseStructure.units[location];
if (notes) {
section.addChild(this.getNotes(notes))
}
}, this);
}
}, this);
container.appendChild(group.render().el);
}, this);
this.$el.append(container);
return this;
},
getGroup: function (chapter, section) {
var group = new NoteGroupView({
chapter: chapter,
section: section
});
this.children.push(group);
return group;
}
}),
tabInfo: {
name: gettext('Location in Course'),
identifier: 'view-course-structure',
icon: 'fa fa-list-ul'
}
});
return CourseStructureView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view'
], function (gettext, TabPanelView, TabView) {
var RecentActivityView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'recent-panel',
title: 'Recent Activity',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ')
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
tabInfo: {
identifier: 'view-recent-activity',
name: gettext('Recent Activity'),
icon: 'fa fa-clock-o'
}
});
return RecentActivityView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view',
'js/edxnotes/views/search_box'
], function (gettext, TabPanelView, TabView, SearchBoxView) {
var SearchResultsView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'search-results-panel',
title: 'Search Results',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
NoResultsViewConstructor: TabPanelView.extend({
id: 'no-results-panel',
title: 'No results found',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
var message = gettext('No results found for "%(query_string)s". Please try searching again.');
this.$el.append($('<p />', {
text: interpolate(message, {
query_string: this.options.searchQuery
}, true)
}));
return this;
}
}),
tabInfo: {
identifier: 'view-search-results',
name: gettext('Search Results'),
icon: 'fa fa-search',
is_closable: true
},
initialize: function (options) {
_.bindAll(this, 'onBeforeSearchStart', 'onSearch', 'onSearchError');
TabView.prototype.initialize.call(this, options);
this.searchResults = null;
this.searchBox = new SearchBoxView({
el: document.getElementById('search-notes-form'),
debug: this.options.debug,
beforeSearchStart: this.onBeforeSearchStart,
search: this.onSearch,
error: this.onSearchError
});
},
renderContent: function () {
this.getLoadingIndicator().focus();
return this.searchPromise.done(_.bind(function () {
this.contentView = this.getSubView();
if (this.contentView) {
this.$('.wrapper-tabs').append(this.contentView.render().$el);
}
}, this));
},
getSubView: function () {
var collection = this.getCollection();
if (collection) {
if (collection.length) {
return new this.PanelConstructor({
collection: collection,
searchQuery: this.searchResults.searchQuery
});
} else {
return new this.NoResultsViewConstructor({
searchQuery: this.searchResults.searchQuery
});
}
}
return null;
},
getCollection: function () {
if (this.searchResults) {
return this.searchResults.collection;
}
return null;
},
onClose: function () {
this.searchResults = null;
},
onBeforeSearchStart: function () {
this.searchDeferred = $.Deferred();
this.searchPromise = this.searchDeferred.promise();
this.hideErrorMessage();
this.searchResults = null;
// If tab doesn't exist, creates it.
if (!this.tabModel) {
this.createTab();
}
// If tab is not already active, makes it active
if (!this.tabModel.isActive()) {
this.tabModel.activate();
} else {
this.render();
}
},
onSearch: function (collection, total, searchQuery) {
this.searchResults = {
collection: collection,
total: total,
searchQuery: searchQuery
};
if (this.searchDeferred) {
this.searchDeferred.resolve();
}
if (this.contentView) {
this.contentView.$el.focus();
}
},
onSearchError: function (errorMessage) {
this.showErrorMessage(errorMessage);
if (this.searchDeferred) {
this.searchDeferred.reject();
}
}
});
return SearchResultsView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/views/tab_item'
], function (_, Backbone, TabItemView) {
var TabsListView = Backbone.View.extend({
tagName: 'ul',
className: 'tabs',
initialize: function (options) {
this.options = options;
this.listenTo(this.collection, {
'add': this.createTab,
'destroy': function (model, collection) {
if (model.isActive() && collection.length) {
collection.at(0).activate();
}
}
});
},
render: function () {
this.collection.each(this.createTab, this);
if (this.collection.length) {
this.collection.at(0).activate();
}
return this;
},
createTab: function (model) {
var tab = new TabItemView({
model: model
});
tab.render().$el.appendTo(this.$el);
return tab;
}
});
return TabsListView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext',
'annotator', 'js/edxnotes/views/visibility_decorator'
], function($, _, Backbone, gettext, Annotator, EdxnotesVisibilityDecorator) {
var ToggleNotesView = Backbone.View.extend({
events: {
'click .action-toggle-notes': 'toggleHandler'
},
errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError');
this.visibility = options.visibility;
this.visibilityUrl = options.visibilityUrl;
this.label = this.$('.utility-control-label');
this.actionLink = this.$('.action-toggle-notes');
this.actionLink.removeClass('is-disabled');
this.actionToggleMessage = this.$('.action-toggle-message');
this.notification = new Annotator.Notification();
},
toggleHandler: function (event) {
event.preventDefault();
this.visibility = !this.visibility;
this.showActionMessage();
this.toggleNotes(this.visibility);
},
toggleNotes: function (visibility) {
if (visibility) {
this.enableNotes();
} else {
this.disableNotes();
}
this.sendRequest();
},
showActionMessage: function () {
// The following lines are necessary to re-trigger the CSS animation on span.action-toggle-message
this.actionToggleMessage.removeClass('is-fleeting');
this.actionToggleMessage.offset().width = this.actionToggleMessage.offset().width;
this.actionToggleMessage.addClass('is-fleeting');
},
enableNotes: function () {
_.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote);
this.actionLink.addClass('is-active').attr('aria-pressed', true);
this.label.text(gettext('Hide notes'));
this.actionToggleMessage.text(gettext('Showing notes'));
},
disableNotes: function () {
EdxnotesVisibilityDecorator.disableNotes();
this.actionLink.removeClass('is-active').attr('aria-pressed', false);
this.label.text(gettext('Show notes'));
this.actionToggleMessage.text(gettext('Hiding notes'));
},
hideErrorMessage: function() {
this.notification.hide();
},
showErrorMessage: function(message) {
this.notification.show(message, Annotator.Notification.ERROR);
},
sendRequest: function () {
return $.ajax({
type: 'PUT',
url: this.visibilityUrl,
dataType: 'json',
data: JSON.stringify({'visibility': this.visibility}),
success: this.onSuccess,
error: this.onError
});
},
onSuccess: function () {
this.hideErrorMessage();
},
onError: function () {
this.showErrorMessage(this.errorMessage);
}
});
return function (visibility, visibilityUrl) {
return new ToggleNotesView({
el: $('.edx-notes-visibility').get(0),
visibility: visibility,
visibilityUrl: visibilityUrl
});
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'js/edxnotes/views/notes_factory'
], function($, _, NotesFactory) {
var parameters = {}, visibility = null,
getIds, createNote, cleanup, factory;
getIds = function () {
return _.map($('.edx-notes-wrapper'), function (element) {
return element.id;
});
};
createNote = function (element, params) {
if (params) {
return NotesFactory.factory(element, params);
}
return null;
};
cleanup = function (ids) {
var list = _.clone(Annotator._instances);
ids = ids || [];
_.each(list, function (instance) {
var id = instance.element.attr('id');
if (!_.contains(ids, id)) {
instance.destroy();
}
});
};
factory = function (element, params, isVisible) {
// When switching sequentials, we need to keep track of the
// parameters of each element and the visibility (that may have been
// changed by the checkbox).
parameters[element.id] = params;
if (_.isNull(visibility)) {
visibility = isVisible;
}
if (visibility) {
// When switching sequentials, the global object Annotator still
// keeps track of the previous instances that were created in an
// array called 'Annotator._instances'. We have to destroy these
// but keep those found on page being loaded (for the case when
// there are more than one HTMLcomponent per vertical).
cleanup(getIds());
return createNote(element, params);
}
return null;
};
return {
factory: factory,
enableNote: function (element) {
createNote(element, parameters[element.id]);
visibility = true;
},
disableNotes: function () {
cleanup();
visibility = false;
},
_setVisibility: function (state) {
visibility = state;
},
}
});
}).call(this, define || RequireJS.define);
<section class="container">
<div class="wrapper-student-notes">
<div class="student-notes">
<div class="title-search-container">
<div class="wrapper-title">
<h1 class="page-title">
Notes
<small class="page-subtitle">Highlights and notes you've made in course content</small>
</h1>
</div>
<div class="wrapper-notes-search">
<form role="search" action="/search_endpoint" method="GET" id="search-notes-form" class="is-hidden">
<label for="search-notes-input" class="sr">Search notes for:</label>
<input type="search" class="search-notes-input" id="search-notes-input" name="note" placeholder="Search notes for...">
<button type="submit" class="search-notes-submit">
<i class="icon fa fa-search"></i>
<span class="sr">Search</span>
</button>
</form>
</div>
</div>
<div class="wrapper-msg is-hidden error urgency-high inline-error">
<div class="msg msg-error">
<div class="msg-content">
<p class="copy" aria-live="polite"></p>
</div>
</div>
</div>
<section class="wrapper-tabs">
<div class="tab-list is-hidden">
<h2 id="tab-view" class="tabs-label">View notes by</h2>
</div>
<div class="ui-loading" tabindex="-1">
<span class="spin">
<i class="icon fa fa-refresh"></i>
</span>
<span class="copy">Loading</span>
</div>
</section>
</div>
</div>
</section>
<div id="edx-notes-wrapper-123" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>
<div id="edx-notes-wrapper-456" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>
<div class="wrapper-utility edx-notes-visibility">
<span class="action-toggle-message">Hiding notes</span>
<button class="utility-control utility-control-button action-toggle-notes is-disabled is-active" aria-pressed="true">
<i class="icon fa fa-pencil"></i>
<span class="utility-control-label sr">Hide notes</span>
</button>
</div>
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NotesCollection', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function () {
this.collection = new NotesCollection(notes);
});
it('can return correct course structure', function () {
var structure = this.collection.getCourseStructure();
expect(structure.chapters).toEqual([
Helpers.getChapter('First Chapter', 1, 0, [2]),
Helpers.getChapter('Second Chapter', 0, 1, [1, 'w_n', 0])
]);
expect(structure.sections).toEqual({
'i4x://section/0': Helpers.getSection('Third Section', 0, ['w_n', 1, 0]),
'i4x://section/1': Helpers.getSection('Second Section', 1, [2]),
'i4x://section/2': Helpers.getSection('First Section', 2, [3])
});
expect(structure.units).toEqual({
'i4x://unit/0': [this.collection.at(0), this.collection.at(1)],
'i4x://unit/1': [this.collection.at(2)],
'i4x://unit/2': [this.collection.at(3)],
'i4x://unit/3': [this.collection.at(4)]
});
});
});
});
define(['jquery'], function($) {
'use strict';
return function (that) {
that.addMatchers({
toContainText: function (text) {
var trimmedText = $.trim($(this.actual).text());
if (text && $.isFunction(text.test)) {
return text.test(trimmedText);
} else {
return trimmedText.indexOf(text) !== -1;
}
},
toHaveLength: function (number) {
return $(this.actual).length === number;
},
toHaveIndex: function (number) {
return $(this.actual).index() === number;
},
toBeInRange: function (min, max) {
return min <= this.actual && this.actual <= max;
},
toBeFocused: function () {
return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement;
}
});
};
});
define(['underscore'], function(_) {
'use strict';
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT,
base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes;
LONG_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Excepteur ',
'sint occaecat cupidatat non proident, sunt in culpa ',
'qui officia deserunt mollit anim id est laborum.'
].join('');
PRUNED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur...'
].join('');
TRUNCATED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Exce'
].join('');
SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt';
base64Encode = function (data) {
var ac, bits, enc, h1, h2, h3, h4, i, o1, o2, o3, r, tmp_arr;
if (btoa) {
// Gecko and Webkit provide native code for this
return btoa(data);
} else {
// Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_encode
// version 1109.2015
i = 0;
ac = 0;
enc = "";
tmp_arr = [];
if (!data) {
return data;
}
data += '';
while (i < data.length) {
o1 = data.charCodeAt(i++);
o2 = data.charCodeAt(i++);
o3 = data.charCodeAt(i++);
bits = o1 << 16 | o2 << 8 | o3;
h1 = bits >> 18 & 0x3f;
h2 = bits >> 12 & 0x3f;
h3 = bits >> 6 & 0x3f;
h4 = bits & 0x3f;
tmp_arr[ac++] = B64.charAt(h1) + B64.charAt(h2) + B64.charAt(h3) + B64.charAt(h4);
}
enc = tmp_arr.join('');
r = data.length % 3;
return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
}
};
makeToken = function() {
var now = (new Date()).getTime() / 1000,
rawToken = {
sub: "sub",
exp: now + 100,
iat: now
};
return 'header.' + base64Encode(JSON.stringify(rawToken)) + '.signature';
};
getChapter = function (name, location, index, children) {
return {
display_name: name,
location: 'i4x://chapter/' + location,
index: index,
children: _.map(children, function (i) {
return 'i4x://section/' + i;
})
};
};
getSection = function (name, location, children) {
return {
display_name: name,
location: 'i4x://section/' + location,
children: _.map(children, function (i) {
return 'i4x://unit/' + i;
})
};
};
getUnit = function (name, location) {
return {
display_name: name,
location: 'i4x://unit/' + location,
url: 'http://example.com'
};
};
getDefaultNotes = function () {
return [
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Note 4'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Third added model',
quote: 'Note 5'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Third Unit', 1),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Note 3'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Second Section', 1, [2]),
unit: getUnit('Second Unit', 2),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 2'
},
{
chapter: getChapter('First Chapter', 1, 0, [2]),
section: getSection('First Section', 2, [3]),
unit: getUnit('First Unit', 3),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 1'
}
];
};
return {
LONG_TEXT: LONG_TEXT,
PRUNED_TEXT: PRUNED_TEXT,
TRUNCATED_TEXT: TRUNCATED_TEXT,
SHORT_TEXT: SHORT_TEXT,
base64Encode: base64Encode,
makeToken: makeToken,
getChapter: getChapter,
getSection: getSection,
getUnit: getUnit,
getDefaultNotes: getDefaultNotes
};
});
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NoteModel', function() {
beforeEach(function () {
this.collection = new NotesCollection([
{quote: Helpers.LONG_TEXT},
{quote: Helpers.SHORT_TEXT}
]);
});
it('has correct values on initialization', function () {
expect(this.collection.at(0).get('is_expanded')).toBeFalsy();
expect(this.collection.at(0).get('show_link')).toBeTruthy();
expect(this.collection.at(1).get('is_expanded')).toBeFalsy();
expect(this.collection.at(1).get('show_link')).toBeFalsy();
});
it('can return appropriate note text', function () {
var model = this.collection.at(0);
// is_expanded = false, show_link = true
expect(model.getNoteText()).toBe(Helpers.PRUNED_TEXT);
model.set('is_expanded', true);
// is_expanded = true, show_link = true
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
model.set('show_link', false);
model.set('is_expanded', false);
// is_expanded = false, show_link = false
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
});
});
});
define([
'js/edxnotes/collections/tabs'
], function(TabsCollection) {
'use strict';
describe('EdxNotes TabModel', function() {
beforeEach(function () {
this.collection = new TabsCollection([{}, {}, {}]);
});
it('when activate current model, all other models are inactivated', function () {
this.collection.at(1).activate();
expect(this.collection.at(1).get('is_active')).toBeTruthy();
expect(this.collection.at(0).get('is_active')).toBeFalsy();
expect(this.collection.at(2).get('is_active')).toBeFalsy();
});
it('can inactivate current model', function () {
var model = this.collection.at(0);
model.activate();
expect(model.get('is_active')).toBeTruthy();
model.inactivate();
expect(model.get('is_active')).toBeFalsy();
});
it('can see correct activity status via isActive', function () {
var model = this.collection.at(0);
model.activate();
expect(model.isActive()).toBeTruthy();
model.inactivate();
expect(model.isActive()).toBeFalsy();
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'annotator', 'logger', 'js/edxnotes/views/notes_factory'
], function($, _, AjaxHelpers, Helpers, Annotator, Logger, NotesFactory) {
'use strict';
describe('EdxNotes Events Plugin', function() {
var note = {
user: 'user-123',
id: 'note-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
},
noteWithoutId = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
beforeEach(function() {
this.annotator = NotesFactory.factory(
$('<div />').get(0), {
endpoint: 'http://example.com/'
}
);
spyOn(Logger, 'log');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should log edx.course.student_notes.viewed event properly', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer,
[note, {user: 'user-456'}, {user: 'user-789', id: 'note-789'}]
]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.viewed', {
'notes': [{'note_id': 'note-123'}, {'note_id': 'note-789'}]
}
);
});
it('should not log edx.course.student_notes.viewed event if all notes are new', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer, [{user: 'user-456'}, {user: 'user-789'}]
]);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should log edx.course.student_notes.added event properly', function() {
var requests = AjaxHelpers.requests(this),
newNote = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
this.annotator.publish('annotationCreated', newNote);
AjaxHelpers.respondWithJson(requests, note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.added', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should log the edx.course.student_notes.edited event properly', function() {
var oldNote = note,
newNote = $.extend({}, note, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': 'text-123',
'old_note_text_truncated': false,
'note_text': 'text-456',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should not log the edx.course.student_notes.edited event if the note is new', function() {
var oldNote = noteWithoutId,
newNote = $.extend({}, noteWithoutId, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).not.toHaveBeenCalled();
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should log the edx.course.student_notes.deleted event properly', function() {
this.annotator.publish('annotationDeleted', note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.deleted', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should not log the edx.course.student_notes.deleted event if the note is new', function() {
this.annotator.publish('annotationDeleted', noteWithoutId);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should truncate values of some fields', function() {
var oldNote = $.extend({}, note, {text: Helpers.LONG_TEXT}),
newNote = $.extend({}, note, {
text: Helpers.LONG_TEXT + '123',
quote: Helpers.LONG_TEXT + '123'
});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe(Helpers.LONG_TEXT);
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': Helpers.TRUNCATED_TEXT,
'old_note_text_truncated': true,
'note_text': Helpers.TRUNCATED_TEXT,
'note_text_truncated': true,
'highlighted_content': Helpers.TRUNCATED_TEXT,
'highlighted_content_truncated': true,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
});
});
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory',
'js/spec/edxnotes/custom_matchers'
], function($, _, Annotator, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes Scroll Plugin', function() {
var annotators, highlights;
function checkAnnotatorIsFrozen(annotator) {
expect(annotator.isFrozen).toBe(true);
expect(annotator.onHighlightMouseover).not.toHaveBeenCalled();
expect(annotator.startViewerHideTimer).not.toHaveBeenCalled();
}
function checkAnnotatorIsUnfrozen(annotator) {
expect(annotator.isFrozen).toBe(false);
expect(annotator.onHighlightMouseover).toHaveBeenCalled();
expect(annotator.startViewerHideTimer).toHaveBeenCalled();
}
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
annotators = [
NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), {
endpoint: 'http://example.com/'
}),
NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), {
endpoint: 'http://example.com/'
})
];
highlights = _.map(annotators, function(annotator) {
spyOn(annotator, 'onHighlightClick').andCallThrough();
spyOn(annotator, 'onHighlightMouseover').andCallThrough();
spyOn(annotator, 'startViewerHideTimer').andCallThrough();
return $('<span></span>', {
'class': 'annotator-hl',
'tabindex': -1,
'text': 'some content'
}).appendTo(annotator.element);
});
spyOn(annotators[0].plugins.Scroller, 'getIdFromLocationHash').andReturn('abc123');
spyOn($.fn, 'unbind').andCallThrough();
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should scroll to a note, open it and freeze the annotator if its id is part of the url hash', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'abc123',
highlights: [highlights[0]]
}]);
annotators[0].onHighlightMouseover.reset();
expect(highlights[0]).toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsFrozen(annotators[0]);
});
it('should not do anything if the url hash contains a wrong id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'def456',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should not do anything if the url hash contains an empty id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: '',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should unbind onNotesLoaded on destruction', function() {
annotators[0].plugins.Scroller.destroy();
expect($.fn.unbind).toHaveBeenCalledWith(
'annotationsLoaded',
annotators[0].plugins.Scroller.onNotesLoaded
);
});
});
});
define([
'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers'
], function(Logger, NotesLogger, customMatchers) {
'use strict';
describe('Edxnotes NotesLogger', function() {
var getLogger = function(id, mode) {
return NotesLogger.getLogger(id, mode);
};
beforeEach(function () {
spyOn(window.console, 'log');
spyOn(window.console, 'error');
spyOn(Logger, 'log');
customMatchers(this);
});
it('keeps a correct history of logs', function() {
var logger = getLogger('id', 1),
logs, log;
logger.log('A log type', 'A first log');
logger.log('A log type', 'A second log');
expect(window.console.log).toHaveBeenCalled();
logs = logger.getHistory();
// Test first log
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A first log');
// Test second log
log = logs[1];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A second log');
});
it('keeps a correct history of errors', function() {
var logger = getLogger('id', 1),
logs, log;
logger.error('An error type', 'A first error');
logger.error('An error type', 'A second error');
expect(window.console.error).toHaveBeenCalled();
logs = logger.getHistory();
// Test first error
log = logs[0];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A first error');
// Test second error
log = logs[1];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A second error');
});
it('can destroy the logger', function() {
var logger = getLogger('id', 1),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(2);
logger.destroy();
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not store the history in silent mode', function() {
var logger = getLogger('id', 0),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not show logs in the console in silent mode', function() {
var logger = getLogger('id', 0);
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
expect(window.console.log).not.toHaveBeenCalled();
expect(window.console.error).not.toHaveBeenCalled();
});
it('can use timers', function() {
var logger = getLogger('id', 1),
now, t0, logs, log;
now = function () {
return (new Date()).getTime();
};
t0 = now();
logger.time('timer');
while (now() - t0 < 200) {}
logger.timeEnd('timer');
logs = logger.getHistory();
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('timer');
expect(log[1][2]).toBeInRange(180, 220);
expect(log[1][3]).toBe('ms');
});
it('can emit an event properly', function () {
var logger = getLogger('id', 0);
logger.emit('event_name', {id: 'some_id'})
expect(Logger.log).toHaveBeenCalledWith('event_name', {
id: 'some_id'
});
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers',
'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'logger',
'js/edxnotes/models/note', 'js/edxnotes/views/note_item',
'js/spec/edxnotes/custom_matchers'
], function(
$, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView,
customMatchers
) {
'use strict';
describe('EdxNotes NoteItemView', function() {
var getView = function (model) {
model = new NoteModel(_.defaults(model || {}, {
id: 'id-123',
user: 'user-123',
usage_id: 'usage_id-123',
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: Helpers.LONG_TEXT,
unit: {
url: 'http://example.com/'
}
}));
return new NoteItemView({model: model}).render();
};
beforeEach(function() {
customMatchers(this);
TemplateHelpers.installTemplate('templates/edxnotes/note-item');
spyOn(Logger, 'log').andCallThrough();
});
it('can be rendered properly', function() {
var view = getView(),
unitLink = view.$('.reference-unit-link').get(0);
expect(view.$el).toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.PRUNED_TEXT);
expect(view.$el).toContainText('More');
view.$('.note-excerpt-more-link').click();
expect(view.$el).toContainText(Helpers.LONG_TEXT);
expect(view.$el).toContainText('Less');
view = getView({quote: Helpers.SHORT_TEXT});
expect(view.$el).not.toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.SHORT_TEXT);
expect(unitLink.hash).toBe('#id-123');
});
it('should display update value and accompanying text', function() {
var view = getView();
expect(view.$('.reference-title').last()).toContainText('Last Edited:');
expect(view.$('.reference-meta').last()).toContainText('December 11, 2014 at 11:12AM');
});
it('should log the edx.student_notes.used_unit_link event properly', function () {
var requests = AjaxHelpers.requests(this),
view = getView();
spyOn(view, 'redirectTo');
view.$('.reference-unit-link').click();
expect(Logger.log).toHaveBeenCalledWith(
'edx.student_notes.used_unit_link',
{
'note_id': 'id-123',
'component_usage_id': 'usage_id-123'
},
null,
{
'timeout': 2000
}
);
expect(view.redirectTo).not.toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(view.redirectTo).toHaveBeenCalledWith('http://example.com/#id-123');
});
});
});
define([
'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers',
'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers'
], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) {
'use strict';
describe('EdxNotes NotesFactory', function() {
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
this.wrapper = document.getElementById('edx-notes-wrapper-123');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('can initialize annotator correctly', function() {
var requests = AjaxHelpers.requests(this),
token = Helpers.makeToken(),
options = {
user: 'a user',
usage_id : 'an usage',
course_id: 'a course'
},
annotator = NotesFactory.factory(this.wrapper, {
endpoint: '/test_endpoint',
user: 'a user',
usageId : 'an usage',
courseId: 'a course',
token: token,
tokenUrl: '/test_token_url'
}),
request = requests[0];
expect(requests).toHaveLength(1);
expect(request.requestHeaders['x-annotator-auth-token']).toBe(token);
expect(annotator.options.auth.tokenUrl).toBe('/test_token_url');
expect(annotator.options.store.prefix).toBe('/test_endpoint');
expect(annotator.options.store.annotationData).toEqual(options);
expect(annotator.options.store.loadFromSearch).toEqual(options);
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'js/edxnotes/views/page_factory', 'js/spec/edxnotes/custom_matchers'
], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes NotesPage', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.view = new NotesFactory({notesList: notes});
});
it('should be displayed properly', function() {
var requests = AjaxHelpers.requests(this),
tab;
expect(this.view.$('#view-search-results')).not.toExist();
tab = this.view.$('#view-recent-activity');
expect(tab).toHaveClass('is-active');
expect(tab.index()).toBe(0);
tab = this.view.$('#view-course-structure');
expect(tab).toExist();
expect(tab.index()).toBe(1);
expect(this.view.$('.tab-panel')).toExist();
this.view.$('.search-notes-input').val('test_query');
this.view.$('.search-notes-submit').click();
AjaxHelpers.respondWithJson(requests, {
total: 0,
rows: []
});
expect(this.view.$('#view-search-results')).toHaveClass('is-active');
expect(this.view.$('#view-recent-activity')).toExist();
expect(this.view.$('#view-course-structure')).toExist();
});
});
});
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/search_box',
'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers) {
'use strict';
describe('EdxNotes SearchBoxView', function() {
var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled;
getSearchBox = function (options) {
options = _.defaults(options || {}, {
el: $('#search-notes-form').get(0),
beforeSearchStart: jasmine.createSpy(),
search: jasmine.createSpy(),
error: jasmine.createSpy(),
complete: jasmine.createSpy()
});
return new SearchBoxView(options);
};
submitForm = function (searchBox, text) {
searchBox.$('.search-notes-input').val(text);
searchBox.$('.search-notes-submit').click();
};
assertBoxIsEnabled = function (searchBox) {
expect(searchBox.$el).not.toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).not.toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeFalsy();
};
assertBoxIsDisabled = function (searchBox) {
expect(searchBox.$el).toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeTruthy();
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
spyOn(Logger, 'log');
this.searchBox = getSearchBox();
});
it('sends a request with proper information on submit the form', function () {
var requests = AjaxHelpers.requests(this),
form = this.searchBox.el,
request;
submitForm(this.searchBox, 'test_text');
request = requests[0];
expect(request.method).toBe(form.method.toUpperCase());
expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'}));
});
it('returns success result', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
expect(this.searchBox.options.beforeSearchStart).toHaveBeenCalledWith(
'test_text'
);
assertBoxIsDisabled(this.searchBox);
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.search).toHaveBeenCalledWith(
jasmine.any(NotesCollection), 2, 'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('should log the edx.student_notes.searched event properly', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
expect(Logger.log).toHaveBeenCalledWith('edx.student_notes.searched', {
'number_of_results': 2,
'search_string': 'test_text'
});
});
it('returns default error message if received data structure is wrong', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {});
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns default error message if network error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithError(requests);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns error message if server error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
requests[0].respond(
500, {'Content-Type': 'application/json'},
JSON.stringify({
error: 'test error message'
})
);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'test error message',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('does not send second request during current search', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
submitForm(this.searchBox, 'another_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(requests).toHaveLength(1);
});
it('returns error message if the field is empty', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, ' ');
expect(requests).toHaveLength(0);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'Please enter a term in the <a href="#search-notes-input"> search field</a>.',
' '
);
});
});
});
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
);
});
});
});
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);
});
});
});
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();
});
});
});
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']);
});
});
});
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);
});
});
});
});
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);
});
});
});
});
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);
});
});
});
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');
});
});
});
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);
});
});
});
(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
......
;(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);
......@@ -8,3 +8,112 @@
// }
// --------------------
// button resetting - overriding the poorly scoped button mixin styling
.annotator-adder button, .annotator-outer button {
@extend %ui-reset-button;
&:focus {
border: none !important;
outline: thin dotted !important;
}
}
// .xmodule_display.xmodule_HtmlModule element - override needed for annotator.js styles
.edx-notes-wrapper .annotator-wrapper {
.annotator-editor.annotator-outer a {
@include transition(none);
font-size: 12px;
line-height: 24px;
font-weight: bold;
color: rgb(54, 54, 54);
&.annotator-focus,
&:hover,
&:focus {
color: rgb(255, 255, 255);
}
}
.annotator-outer {
* {
line-height: 1;
}
ul {
margin: 0;
padding: 0 !important;
color: #222;
list-style: none !important;
li {
margin-bottom: 0;
}
}
&.annotator-viewer .annotator-controls button {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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') !important;
&.annotator-edit {
background-position: 0 -60px !important;
}
&.annotator-delete {
background-position: 0 -75px !important;
}
&.annotator-link {
background-position: 0 -270px !important;
}
}
}
}
/* Added to avoid having to set these in Annotator._setupDynamicStyle via an expensive Util.maxZIndex(...) call. */
.annotator-adder, .annotator-outer, .annotator-notice {
z-index: 999999;
}
.annotator-filter {
z-index: 99999;
}
// rotate clockwise
@include keyframes(rotateCW) {
0% {
@include transform(rotate(0deg));
}
50% {
@include transform(rotate(180deg));
}
100% {
@include transform(rotate(360deg));
}
}
// canned animation - use if you want out of the box/non-customized anim
%anim-rotateCW {
@include animation(rotateCW $tmg-s1 linear infinite);
}
.ui-loading {
@include animation(fadeIn $tmg-f2 linear 1);
@extend %ui-well;
@extend %t-copy-base;
opacity: .6;
background-color: $white;
padding: ($baseline*1.5) $baseline;
text-align: center;
.spin {
@extend %anim-rotateCW;
display: inline-block;
}
.copy {
padding-left: ($baseline/4);
}
}
......@@ -261,3 +261,21 @@
@-webkit-keyframes fade-in-animation{ @include fade-in-keyframes; }
@-moz-keyframes fade-in-animation{ @include fade-in-keyframes; }
@keyframes fade-in-animation{ @include fade-in-keyframes; }
// +utility animations
// --------------------
// pulse - double + fade out
@include keyframes(pulse-out) {
0%, 100% {
opacity: 0;
}
25%, 75% {
opacity: 1.0;
}
100% {
opacity: 0;
}
}
......@@ -164,3 +164,42 @@
white-space: nowrap;
text-overflow: ellipsis;
}
// border control
%no-border-top {
border-top: none;
}
%no-border-bottom {
border-bottom: none;
}
%no-border-left {
border-left: none;
}
%no-border-right {
border-right: none;
}
// outline
%no-outline {
outline: none;
}
// shame-based mixins to centrally override poor styling
%shame-link-base {
color: $link-color;
&:hover, &:focus {
color: saturate($link-color, 50%);
}
}
%shame-link-text {
@extend %shame-link-base;
&:hover, &:focus {
text-decoration: underline !important;
}
}
......@@ -329,6 +329,8 @@ $header-graphic-sub-color: $m-gray-d2;
$error-color: $error-red;
$warning-color: $m-pink;
$confirm-color: $m-green;
$active-color: $blue;
$highlight-color: rgb(255,255,0);
// Notifications
$notify-banner-bg-1: rgb(56,56,56);
......@@ -444,3 +446,13 @@ $blue1: #4A90E2;
$blue2: #00A1E5;
$green1: #61A12E;
$red1: #D0021B;
// +case: search/result highlight
// --------------------
$result-highlight-color-base: rgba($highlight-color, 0.25);
// +feature: student notes
// --------------------
$student-notes-highlight-color-base: saturate($yellow, 65%);
$student-notes-highlight-color: tint($student-notes-highlight-color-base, 50%);
$student-notes-highlight-color-focus: $student-notes-highlight-color-base;
......@@ -27,36 +27,37 @@
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/navigation'; // all archetypes of navigation
// Course base / layout styles
// course - base
@import 'course/layout/courseware_header';
@import 'course/layout/footer';
@import 'course/base/mixins';
@import 'course/base/base';
@import 'course/base/extends';
@import 'xmodule/modules/css/module-styles.scss';
// courseware
@import 'course/courseware/courseware';
@import 'course/courseware/sidebar';
@import 'course/courseware/amplifier';
@import 'course/layout/calculator';
@import 'course/layout/timer';
@import 'course/layout/chat';
// course-specific courseware (all styles in these files should be gated by a
// course-specific class). This should be replaced with a better way of
// providing course-specific styling.
// course - modules
@import 'course/modules/student-notes'; // student notes
@import 'course/modules/calculator'; // calculator utility
@import 'course/modules/timer'; // timer
@import 'course/modules/chat'; // chat utility
// course - specific courses
@import "course/courseware/courses/_cs188.scss";
// wiki
// course - wiki
@import "course/wiki/basic-html";
@import "course/wiki/sidebar";
@import "course/wiki/create";
@import "course/wiki/wiki";
@import "course/wiki/table";
// pages
// course - views
@import "course/info";
@import "course/syllabus"; // TODO arjun replace w/ custom tabs, see courseware/courses.py
@import "course/textbook";
......@@ -66,12 +67,13 @@
@import "course/staff_grading";
@import "course/rubric";
@import "course/open_ended_grading";
@import "course/student-notes";
// instructor
// course - instructor-only views
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
@import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss";
// discussion
// course - discussion
@import "course/discussion/form-wmd-toolbar";
......@@ -27,36 +27,37 @@
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/navigation'; // all archetypes of navigation
// Course base / layout styles
// course - base
@import 'course/layout/courseware_header';
@import 'course/layout/footer';
@import 'course/base/mixins';
@import 'course/base/base';
@import 'course/base/extends';
@import 'xmodule/modules/css/module-styles.scss';
// courseware
@import 'course/courseware/courseware';
@import 'course/courseware/sidebar';
@import 'course/courseware/amplifier';
@import 'course/layout/calculator';
@import 'course/layout/timer';
@import 'course/layout/chat';
// course-specific courseware (all styles in these files should be gated by a
// course-specific class). This should be replaced with a better way of
// providing course-specific styling.
// course - modules
@import 'course/modules/student-notes'; // student notes
@import 'course/modules/calculator'; // calculator utility
@import 'course/modules/timer'; // timer
@import 'course/modules/chat'; // chat utility
// course - specific courses
@import "course/courseware/courses/_cs188.scss";
// wiki
// course - wiki
@import "course/wiki/basic-html";
@import "course/wiki/sidebar";
@import "course/wiki/create";
@import "course/wiki/wiki";
@import "course/wiki/table";
// pages
// course - views
@import "course/info";
@import "course/syllabus"; // TODO arjun replace w/ custom tabs, see courseware/courses.py
@import "course/textbook";
......@@ -66,12 +67,13 @@
@import "course/staff_grading";
@import "course/rubric";
@import "course/open_ended_grading";
@import "course/student-notes";
// instructor
// course - instructor-only views
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
@import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss";
// discussion
// course - discussion
@import "course/discussion/form-wmd-toolbar";
// LMS -- views -- student notes
// ====================
// in this document:
// --------------------
// +notes
// +base
// ++header +and search
// +local variables/utilities
// +individual group of notes
// +tabbed views
// +search - no results
// +search - error
// +case - no notes made
// +notes:
// --------------------
// * this Sass partial contains all of the styling needed for the student notes listing view.
// * for other notes styling referenced here, see the Sass partial contains the in-line student notes UI.
// +local variables/utilities:
// --------------------
$divider-visual-primary: ($baseline/5) solid $gray-l4;
$divider-visual-secondary: ($baseline/10) solid $gray-l4;
$divider-visual-tertiary: ($baseline/20) solid $gray-l4;
%notes-tab-control {
@include transition(none);
@extend %shame-link-base;
display: inline-block;
vertical-align: middle;
border-bottom: ($baseline/5) solid $transparent;
}
.view-student-notes {
// +base:
// --------------------
.wrapper-student-notes {
@include clearfix();
padding-bottom: $baseline;
.student-notes {
@include clearfix();
@extend .content; // needed extend carried over from course handouts UI, but should be cleaned up
width: 100%;
}
}
// +header +and search:
// --------------------
.title-search-container {
@include clearfix();
margin-bottom: $baseline;
.wrapper-title {
@include float(left);
width: flex-grid(7,12);
.page-title {
@extend %t-title4;
@extend %t-weight1;
margin-bottom: 0;
.page-subtitle {
@extend %t-title7;
@extend %t-weight2;
display: block;
margin-top: ($baseline/4);
color: $gray-l1;
letter-spacing: 0;
}
}
}
.wrapper-notes-search {
@include float(right);
width: flex-grid(5,12);
@include text-align(right);
}
.search-notes-input, .search-notes-submit {
display: inline-block;
vertical-align: middle;
}
.search-notes-input {
@extend %t-demi-strong;
position: relative;
@include right(-6px); // manually positioning input right next to submit
width: 55%;
padding: ($baseline/2) ($baseline*0.75);
color: $gray-d3;
}
.search-notes-submit {
@extend %btn-inherited-primary;
@extend %t-action2;
padding: 8px $baseline 9px $baseline; // manually syncing up height with search input
}
}
// +individual group of notes
// --------------------
.note-group {
border-top: $divider-visual-primary;
margin: 0;
padding-top: ($baseline*1.5);
// course structure labels
.course-title {
@extend %t-title6;
@extend %t-weight4;
margin: 0 0 ($baseline/2) 0;
color: $gray-d3;
}
.course-subtitle {
@extend %t-title7;
@extend %t-weight4;
margin: 0 0 ($baseline/4) 0;
border-bottom: $divider-visual-tertiary;
padding-bottom: ($baseline/2);
color: $gray-d3;
}
// individual note
.note {
@include clearfix();
margin: ($baseline*1.5) 0;
.wrapper-note-excerpts {
@include transition(box-shadow $tmg-avg ease-in-out 0, border-color $tmg-avg ease-in-out 0);
display: inline-block;
width: flex-grid(9, 12);
border: 1px solid $gray-l5;
border-radius: ($baseline/10);
// note - highlighted content
.note-excerpt {
@include transition(background-color $tmg-avg ease-in-out 0);
padding: $baseline;
background: $student-notes-highlight-color;
.note-excerpt-p,
.note-excerpt-ul,
.note-excerpt-ol {
@extend %t-copy-base;
}
}
.note-excerpt-more-link {
@extend %t-copy-sub1;
@extend %t-weight2;
@extend %shame-link-text;
display: inline;
@include margin-left($baseline/4);
}
// note - comment made on highlighted content
.note-comments {
@extend %ui-no-list;
border-top: ($baseline/5) solid $student-notes-highlight-color-focus;
.note-comment {
@include transition(color $tmg-avg ease-in-out 0);
padding: ($baseline*0.75) $baseline;
color: $gray;
.note-comment-title {
@extend %t-title8;
letter-spacing: ($baseline/20);
margin: 0 0 ($baseline/4) 0;
color: $gray-l2;
}
.note-comment-p,
.note-comment-ul,
.note-comment-ol {
@extend %t-copy-sub1;
@extend %t-weight2;
padding: 0;
margin: 0;
background: transparent;
}
.note-comment-ul,
.note-comment-ol {
padding: auto;
margin: auto;
}
// CASE: when a comment has a term that matches a notes search query
.note-highlight {
background-color: $result-highlight-color-base;
}
}
}
}
// note reference
.reference {
@extend %t-copy-sub1;
display: inline-block;
width: flex-grid(3, 12);
vertical-align: top;
.wrapper-reference-content {
padding: 0 $baseline;
color: $gray-l2;
.reference-title {
@extend %t-title8;
@extend %t-weight3;
margin-top: $baseline;
text-transform: uppercase;
letter-spacing: ($baseline/20);
color: $gray-l2;
// CASE: first reference title of a note
&:first-child {
margin-top: 0;
}
}
.reference-meta {
@extend %t-weight2;
color: $m-gray-d2;
}
// needed for poor base LMS styling scope
a.reference-meta {
@extend %shame-link-text;
}
}
}
// STATE: hover/focus
&:hover, &:focus {
.wrapper-note-excerpts {
box-shadow: 0 2px 0 1px $shadow-l2;
border-color: $gray-l4;
}
.note-excerpt {
background: $student-notes-highlight-color-focus;
}
.note-comment {
color: $gray-d2;
}
}
}
}
// +tabbed views
// --------------------
.wrapper-tabs {
.tab-panel, .inline-error, .ui-loading {
@extend %no-outline;
}
.tab-panel.note-group {
padding-top: 0;
}
.inline-error {
margin: ($baseline/2) 0;
border-bottom: 1px solid $red;
padding: 0 0 ($baseline/2) 0;
color: $red;
}
.tab-list {
@include clearfix();
position: relative;
top: ($baseline/5);
.tabs-label, .tabs {
display: inline-block;
vertical-align: middle;
}
.tabs-label {
@extend %hd-lv5;
margin-bottom: 0;
padding: ($baseline*0.75) 0;
@include padding-right($baseline);
color: $gray-l2;
font-weight: $font-semibold !important; // needed for poor base LMS styling scope
}
.tabs {
@include clearfix();
@extend %ui-no-list;
position: relative;
bottom: -($baseline/4);
}
.tab {
position: relative;
display: inline;
.tab-label {
@extend %notes-tab-control;
padding: ($baseline/2) ($baseline*0.75);
text-align: center;
.icon {
@include margin-right($baseline/10);
}
}
// STATE: active/current tab being viewed
&.is-active {
.tab-label {
border-bottom-color: $gray-d3;
color: $gray-d3;
}
// CASE: tab-label can be closed
.action-close {
border-bottom: ($baseline/5) solid $gray-d3;
}
}
// CASE: tab-label can be closed
.action-close {
@extend %notes-tab-control;
position: relative;
@include left(-($baseline*0.75));
padding: ($baseline/2);
}
}
}
}
// +search - no results
// --------------------
// NOTE: not a lot of elements/classes to reference in this DOM
#no-results-panel {
p {
@extend %t-copy-lead1;
margin: ($baseline*1.5) 0;
}
}
// +search - error
// --------------------
.wrapper-msg {
margin-bottom: $baseline;
}
// +case - no notes made
// --------------------
.placeholder {
background: $gray-l5;
border-top: ($baseline/4) solid $active-color;
padding: ($baseline*1.5);
}
.placeholder-title {
@extend %hd-lv3;
margin-bottom: $baseline;
text-transform: none; // reset needed for poor h2 element styling
letter-spacing: 0; // reset needed for poor h2 element styling
}
.placeholder-copy {
@extend %t-copy-sub1;
ul {
@extend %ui-no-list;
li {
@extend %wipe-last-child;
display: block;
margin-bottom: ($baseline/2);
}
}
p, ul {
margin-bottom: $baseline;
}
}
.placeholder-cta-copy {
@extend %t-strong;
a {
@extend %t-strong;
}
}
}
// LMS -- modules -- calculator
// ====================
div.calc-main {
bottom: -126px;
left: 0;
......@@ -5,39 +8,37 @@ div.calc-main {
@include transition(bottom $tmg-avg linear 0s);
-webkit-appearance: none;
width: 100%;
z-index: 99;
&.open {
bottom: -36px;
}
a.calc {
@include text-hide();
background: url("../images/calc-icon.png") rgba(#111, .9) no-repeat center;
.calc {
@include transition(background-color $tmg-f2 ease-in-out 0s);
background: url("../images/calc-icon.png") $black-t1 no-repeat center;
border-bottom: 0;
border-radius: 3px 3px 0 0;
color: $white;
float: right;
height: 20px;
display: inline-block;
margin-right: ($baseline/2);
padding: 8px 12px;
height: $baseline;
margin-right: ($baseline*0.75);
padding: $baseline;
position: relative;
top: -45px;
width: 16px;
top: -42px;
width: ($baseline*0.75);
&:hover, &:focus {
opacity: 0.8;
background-color: $gray-d1;
}
&.closed {
background-image: url("../images/close-calc-icon.png");
background-color: $black;
top: -36px;
}
}
div#calculator_wrapper {
background: rgba(#111, .9);
background: $black;
clear: both;
max-height: 90px;
position: relative;
......
/* Chat
-------------------------------------------------- */
// LMS -- modules -- chat
// ====================
#chat-wrapper {
position: fixed;
bottom: 0;
......
// LMS -- modules -- student notes
// ====================
// in this document:
// --------------------
// +notes
// +local variables/utilities
// +toggling notes
// +individual note (in context)
// +creating/editing notes
// +listing notes
// +necessary, but ugly overrides
// +notes:
// --------------------
// this Sass partial contains all of the styling needed for the in-line student notes UI.
// +local variables/utilities:
// --------------------
$notes-annotator-background-light: rgb(251, 251, 251); // taken from annotatorJS base colors
$notes-annotator-background-med: rgb(214, 214, 214); // taken from annotatorJS base colors
$notes-annotator-background-dark: rgba(122,122,122,0.6); // taken from annotatorJS base colors
%notes-reset-background {
background-image: none !important;
background-repeat: none !important;
background-position: 0 0 !important;
}
%notes-reset-font {
font-family: $f-sans-serif !important;
font-style: normal !important;
font-weight: $font-regular !important;
}
%notes-reset-icon {
font-family: FontAwesome !important;
font-style: normal !important;
text-indent: 0 !important;
}
%notes-bubble {
border: ($baseline/20) solid $notes-annotator-background-dark !important;
border-radius: ($baseline/10);
box-shadow: 0 ($baseline/10) 0 ($baseline/20) $shadow-l2 !important;
background: $notes-annotator-background-light !important; // syncing to vendor triangle color
}
// +toggling notes
// --------------------
.edx-notes-visibility {
.edx-notes-visibility-error {
@extend %t-copy-sub2;
@extend %text-truncated;
position: relative;
bottom: -($baseline/20); // needed to sync up with current rogue/more complex calc utility alignment
max-width: ($baseline*15);
display: none;
vertical-align: bottom;
@include margin-right(-($baseline/4));
@include border-right(($baseline/4) solid $error-color);
padding: ($baseline/2) $baseline;
background: $black-t3;
text-align: center;
color: $white;
}
// STATE: has error
&.has-error {
.edx-notes-visibility-error {
display: inline-block;
}
.utility-control {
color: $error-color;
}
}
}
// CASE: annotator error in toggling notes (vendor customization)
.annotator-notice {
@extend %t-weight4;
@extend %t-copy-sub1;
padding: ($baseline/4) $baseline;
background: $black-t3;
}
// CASE: annotator error in toggling notes
// vendor customization
.annotator-notice {
@extend %t-weight4;
@extend %t-copy-sub1;
padding: ($baseline/2) $baseline;
background: $gray-d4;
}
// vendor customization
.annotator-notice-error {
border-color: $error-color;
}
// +individual note (in context)
// --------------------
.annotator-outer.annotator-outer {
@extend %ui-depth4;
@extend %notes-reset-font;
}
// bubble
.annotator-widget.annotator-widget {
@extend %notes-bubble;
}
.annotator-item {
padding: ($baseline/2) !important;
}
// +creating/editing notes (overrides for vendor styling)
// --------------------
// adding
.annotator-adder {
@extend %notes-reset-background;
button {
@extend %notes-bubble;
position: relative;
display: block;
&:after {
@extend %notes-reset-icon;
@extend %shame-link-base;
@include font-size(30);
position: absolute;
top: 35%;
@include left(15%);
content: "\f14b";
}
// using annotatorJS triangle styling for adder
&:before {
position: absolute;
@include left(8px);
bottom: -($baseline/2);
display: block;
width: 18px;
height: ($baseline/2);
content: "";
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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);
background-position: 0 0;
}
}
}
// editing
.annotator-editor {
.annotator-controls {
@include text-align(left);
@include clearfix();
background: $notes-annotator-background-med !important; //matches annotator JS editing bubble triangle color
font-family: $f-sans-serif !important;
padding: 8px;
border: none !important;
border-radius: 0 !important;
// actions
.annotator-save, .annotator-cancel {
@extend %notes-reset-background;
font-family: $f-sans-serif !important;
font-size: 14px !important;
padding: ($baseline/4) ($baseline/2) !important;
border: none;
box-shadow: none;
text-shadow: none !important;
// removing vendor icons
&:after {
display: none !important;
}
}
.annotator-save {
@include float(left);
}
.annotator-cancel {
background-color: $transparent !important;
}
}
.annotator-item {
textarea {
@extend %notes-reset-font;
@extend %t-demi-strong;
padding: ($baseline/5) !important;
font-size: 14px !important;
line-height: 22px !important;
color: $gray-d3 !important;
background: $notes-annotator-background-light !important; //matches annotator JS editing bubble triangle color
// STATE: hover/focus
&:hover, &:focus {
background: $notes-annotator-background-light;
}
}
}
}
// +listing notes (overrides for vendor styling)
// --------------------
// highlight
.annotator-hl {
background: $student-notes-highlight-color-focus;
}
// content
.annotator-viewer {
// poorly scoped selector for content of a note's comment
div:first-of-type {
@extend %notes-reset-font;
padding: ($baseline/4) !important;
font-size: 14px !important;
line-height: 22px !important;
color: $gray-d2 !important;
}
// controls
.annotator-controls {
// RTL support
@include right(0);
top: 0;
@include float(right);
@include padding-left($baseline/4);
.annotator-delete, .annotator-edit {
position: relative;
display: inline-block;
vertical-align: middle;
&:before {
@extend %notes-reset-icon;
@extend %shame-link-base;
@extend %t-icon4;
position: absolute;
}
}
.annotator-edit {
@include margin-right($baseline/2);
&:before {
top: 0;
@include left(0);
content: "\f044";
}
}
.annotator-delete {
&:before {
top: -($baseline/20);
@include left(0);
content: "\f00d";
}
}
}
}
// +necessary, but ugly overrides
// --------------------
.edx-notes-wrapper .annotator-wrapper.annotator-wrapper .annotator-outer.annotator-viewer .annotator-controls button {
@extend %notes-reset-background;
opacity: 1.0;
}
.edx-notes-wrapper .annotator-wrapper .annotator-editor.annotator-outer a.annotator-save {
@extend %btn-inherited-primary;
@extend %t-action2;
}
.edx-notes-wrapper .annotator-wrapper .annotator-editor.annotator-outer a.annotator-cancel {
@extend %shame-link-base;
@extend %t-action2;
@extend %t-regular;
}
// LMS -- modules -- student notes
// ====================
div.timer-main {
@extend %ui-depth2;
position: fixed;
......
// LMS -- elements -- navigation
// ====================
// in this document:
// --------------------
// +notes
// +skip navigation
// +utility navigation
// +toggling utilities
// +case - calculator spacing
// +notes:
// --------------------
// this Sass partial should have its contents eventually abstracted out so that onboarding/non-coureware navigation is separate from in course-based navigation systems
// +skip navigation
// --------------------
%nav-skip {
@extend %text-sr;
}
.nav-contents, .nav-skip {
@extend %nav-skip;
}
// +utility navigation (course utiltiies)
// --------------------
.nav-utilities {
@extend %ui-depth3;
position: fixed;
right: ($baseline/4);
bottom: 0;
.wrapper-utility {
@extend %wipe-last-child;
display: inline-block;
vertical-align: bottom;
@include margin-right(6px);
}
.utility-control {
@include transition(background-color $tmg-f2 ease-in-out 0s, color $tmg-f2 ease-in-out 0s);
position: relative;
bottom: -($baseline/5);
display: inline-block;
vertical-align: middle;
padding: ($baseline/2) ($baseline*0.75) ($baseline*0.75) ($baseline*0.75);
background: $black-t1;
color: $white;
// STATE: hover/active
&:hover, &:active {
background: $gray-d1;
}
// STATE: is active/in use
&.is-active {
background: $gray-d1;
}
}
// specific reset styling for any controls that are button elements
.utility-control-button {
border: none;
box-shadow: none;
text-shadow: none;
font-size: inherit;
font-weight: inherit;
line-height: 0;
border-radius: 0;
// STATE: hover/active
&:hover, &:active, &:focus {
border: none;
box-shadow: none;
}
}
// specific utility navigation - student notes toggling
.action-toggle-notes {
@extend %no-outline;
// STATE: is active/in use
&.is-active {
color: $student-notes-highlight-color-base;
}
}
// +toggling utilities
// --------------------
.action-toggle-message {
@extend %t-title8;
@extend %t-strong;
position: absolute;
bottom: 0;
@include right($baseline*2.5);
display: inline-block;
min-width: ($baseline*5);
padding: ($baseline/2) ($baseline*0.75);
opacity: 0;
background-color: $gray-d1;
color: $white;
text-align: center;
// STATE: is fleeting/temporary
&.is-fleeting {
@include animation(pulse-out $tmg-s2 ease-in-out);
}
}
// +case - calculator spacing (needed for overriding calculator positioning)
// --------------------
&.has-utility-calculator {
@include right($baseline*2.50);
}
}
......@@ -120,6 +120,10 @@
border-top: 3px solid $alert-color;
}
&.error {
border-top: 3px solid $error-color;
}
&.warning {
border-top: 3px solid $warning-color;
}
......
......@@ -196,11 +196,11 @@
// typography weights
%t-weight1 {
font-weight: 300;
font-weight: $font-light;
}
%t-weight2 {
font-weight: 400;
font-weight: $font-regular;
}
%t-weight3 {
......@@ -208,11 +208,11 @@
}
%t-weight4 {
font-weight: 600;
font-weight: $font-semibold;
}
%t-weight5 {
font-weight: 700;
font-weight: $font-bold;
}
// ====================
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<div class="calc-main">
<button title="${_('Open Calculator')}" aria-controls="calculator_wrapper" aria-expanded="false" class="calc utility-control-button">
<span class="utility-control-label sr">${_("Open Calculator")}</span>
</button>
<div id="calculator_wrapper">
<form id="calculator">
<div class="input-wrapper">
<input type="text" id="calculator_input" title="${_('Calculator Input Field')}" tabindex="-1" />
<div class="help-wrapper">
<p class="sr" id="hint-instructions">${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}</p>
<a id="calculator_hint" href="#" role="button" aria-haspopup="true" tabindex="-1" aria-describedby="hint-instructions">${_("Hints")}</a>
<ul id="calculator_input_help" class="help" aria-activedescendant="hint-moreinfo" role="tooltip" aria-hidden="true">
<li class="hint-item" id="hint-moreinfo" tabindex="-1">
<p><span class="bold">${_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}edX Guide for Students{guide_link_end}.").format(
math_link_start='<a href="http://edx-guide-for-students.readthedocs.org/en/latest/SFD_mathformatting.html">',
math_link_end='</a>',
guide_link_start='<a href="http://edx-guide-for-students.readthedocs.org/en/latest/index.html">',
guide_link_end='</a>',
)}</span></p>
</li>
<li class="hint-item" id="hint-tips" tabindex="-1"><p><span class="bold">${_("Tips")}:</span> </p>
<ul>
<li class="hint-item" id="hint-paren" tabindex="-1"><p>${_("Use parentheses () to make expressions clear. You can use parentheses inside other parentheses.")}</p></li>
<li class="hint-item" id="hint-spaces" tabindex="-1"><p>${_("Do not use spaces in expressions.")}</p></li>
<li class="hint-item" id="hint-howto-constants" tabindex="-1"><p>${_("For constants, indicate multiplication explicitly (example: 5*c).")}</p></li>
<li class="hint-item" id="hint-howto-maffixes" tabindex="-1"><p>${_("For affixes, type the number and affix without a space (example: 5c).")}</p></li>
<li class="hint-item" id="hint-howto-functions" tabindex="-1"><p>${_("For functions, type the name of the function, then the expression in parentheses.")}</p></li>
</ul>
</li>
<li class="hint-item" id="hint-list" tabindex="-1">
<table class="calculator-input-help-table">
<tbody>
<tr>
<th scope="col">${_("To Use")}</th>
<th scope="col">${_("Type")}</th>
<th scope="col">${_("Examples")}</th>
</tr>
<tr>
<th scope="row">${_("Numbers")}</th>
<td>${_("Integers")}<br />
${_("Fractions")}<br />
${_("Decimals")}
</td>
<td>2520<br />
2/3<br />
3.14, .98
</td>
</tr>
<tr>
<th scope="row">${_("Operators")}</th>
## Translators: Please do not translate mathematical symbols.
<td>${_("+ - * / (add, subtract, multiply, divide)")}<br />
## Translators: Please do not translate mathematical symbols.
${_("^ (raise to a power)")}<br />
## Translators: Please do not translate mathematical symbols.
${_("_ (add a subscript)")}<br />
## Translators: Please do not translate mathematical symbols.
${_("|| (parallel resistors)")}
</td>
<td>x+(2*y)/x-1
x^(n+1)<br />
v_IN+v_OUT<br />
1||2
</td>
</tr>
<tr>
<th scope="row">${_("Greek letters")}</th>
<td>${_("Name of letter")}</td>
<td>alpha<br />
lambda
</td>
</tr>
<tr>
<th scope="row">${_("Constants")}</th>
<td>c, e, g, i, j, k, pi, q, T</td>
<td>20*c<br />
418*T
</td>
</tr>
<tr>
<th scope="row">${_("Affixes")}</th>
<td>${_("Percent sign (%) and metric affixes (d, c, m, u, n, p, k, M, G, T)")}</td>
<td>20%<br />
20c<br />
418T
</td>
</tr>
<tr>
<th scope="row">${_("Basic functions")}</th>
<td>abs, exp, fact or factorial, ln, log2, log10, sqrt</td>
<td>abs(x+y)<br />
sqrt(x^2-y)
</td>
</tr>
<tr>
<th scope="row">${_("Trigonometric functions")}</th>
<td>sin, cos, tan, sec, csc, cot<br />
arcsin, sinh, arcsinh, etc.<br />
</td>
<td>sin(4x+y)<br />
arccsch(4x+y)
</td>
<td></td>
</tr>
<tr>
## Translators: Please see http://en.wikipedia.org/wiki/Scientific_notation
<th scope="row">${_("Scientific notation")}</th>
## Translators: 10^ is a mathematical symbol. Please do not translate.
<td>${_("10^ and the exponent")}</td>
<td>10^-9</td>
</tr>
<tr>
## Translators: this is part of scientific notation. Please see http://en.wikipedia.org/wiki/Scientific_notation#E_notation
<th scope="row">${_("e notation")}</th>
## Translators: 1e is a mathematical symbol. Please do not translate.
<td>${_("1e and the exponent")}</td>
<td>1e-9</td>
</tr>
</tbody>
</table>
</li>
</ul>
</div>
</div>
<input id="calculator_button" type="submit" title="${_('Calculate')}" value="=" aria-label="${_('Calculate')}" value="=" tabindex="-1" />
<input type="text" id="calculator_output" title="${_('Calculator Output Field')}" readonly tabindex="-1" />
</form>
</div>
</div>
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<div id="chat-wrapper">
<div id="chat-toggle" class="closed">
<span id="chat-open">${_('Open Chat')} <em class="icon fa fa-chevron-up"></em></span>
<span id="chat-close">${_('Close Chat')} <em class="icon fa fa-chevron-down"></em></span>
</div>
<div id="chat-block">
## The Candy.js plugin wants to render in an element with #candy
<div id="candy"></div>
</div>
</div>
<%! from django.utils.translation import ugettext as _ %>
<%! from django.template.defaultfilters import escapejs %>
<%! from microsite_configuration import page_title_breadcrumbs %>
<%! from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled %>
<%inherit file="/main.html" />
<%namespace name='static' file='/static_content.html'/>
<%def name="course_name()">
......@@ -214,146 +215,23 @@ ${fragment.foot_html()}
</div>
</div>
% if show_chat:
<div id="chat-wrapper">
<div id="chat-toggle" class="closed">
<span id="chat-open">Open Chat <em class="icon fa fa-chevron-up"></em></span>
<span id="chat-close">Close Chat <em class="icon fa fa-chevron-down"></em></span>
</div>
<div id="chat-block">
## The Candy.js plugin wants to render in an element with #candy
<div id="candy"></div>
</div>
</div>
% endif
% if course.show_calculator:
<div class="calc-main">
<a title="${_('Open Calculator')}" href="#" role="button" aria-controls="calculator_wrapper" aria-expanded="false" class="calc">${_("Open Calculator")}</a>
<div id="calculator_wrapper">
<form id="calculator">
<div class="input-wrapper">
<input type="text" id="calculator_input" title="${_('Calculator Input Field')}" tabindex="-1" />
<div class="help-wrapper">
<p class="sr" id="hint-instructions">${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}</p>
<a id="calculator_hint" href="#" role="button" aria-haspopup="true" tabindex="-1" aria-describedby="hint-instructions">${_("Hints")}</a>
<nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}">
<h2 class="sr nav-utilities-title">${_('Course Utilities Navigation')}</h2>
<ul id="calculator_input_help" class="help" aria-activedescendant="hint-moreinfo" role="tooltip" aria-hidden="true">
<li class="hint-item" id="hint-moreinfo" tabindex="-1">
<p><span class="bold">${_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}edX Guide for Students{guide_link_end}.").format(
math_link_start='<a href="http://edx-guide-for-students.readthedocs.org/en/latest/SFD_mathformatting.html">',
math_link_end='</a>',
guide_link_start='<a href="http://edx-guide-for-students.readthedocs.org/en/latest/index.html">',
guide_link_end='</a>',
)}</span></p>
</li>
<li class="hint-item" id="hint-tips" tabindex="-1"><p><span class="bold">${_("Tips")}:</span> </p>
<ul>
<li class="hint-item" id="hint-paren" tabindex="-1"><p>Use parentheses () to make expressions clear. You can use parentheses inside other parentheses.</p></li>
<li class="hint-item" id="hint-spaces" tabindex="-1"><p>Do not use spaces in expressions.</p></li>
<li class="hint-item" id="hint-howto-constants" tabindex="-1"><p>For constants, indicate multiplication explicitly (example: 5*c).</p></li>
<li class="hint-item" id="hint-howto-maffixes" tabindex="-1"><p>For affixes, type the number and affix without a space (example: 5c).</p></li>
<li class="hint-item" id="hint-howto-functions" tabindex="-1"><p>For functions, type the name of the function, then the expression in parentheses.</p></li>
</ul>
</li>
## Utility: Chat
% if show_chat:
<%include file="/chat/toggle_chat.html" />
% endif
<li class="hint-item" id="hint-list" tabindex="-1">
<table class="calculator-input-help-table">
<tbody>
<tr>
<th scope="col">${_("To Use")}</th>
<th scope="col">${_("Type")}</th>
<th scope="col">${_("Examples")}</th>
</tr>
<tr>
<th scope="row">${_("Numbers")}</th>
<td>Integers<br />
Fractions<br />
Decimals
</td>
<td>2520<br />
2/3<br />
3.14, .98
</td>
</tr>
<tr>
<th scope="row">${_("Operators")}</th>
<td>+ - * / (add, subtract, multiply, divide)<br />
^ (raise to a power)<br />
_ (add a subscript)<br />
|| (parallel resistors)
</td>
<td>x+(2*y)/x-1
x^(n+1)<br />
v_IN+v_OUT<br />
1||2
</td>
</tr>
<tr>
<th scope="row">${_("Greek letters")}</th>
<td>Name of letter</td>
<td>alpha<br />
lambda
</td>
</tr>
<tr>
<th scope="row">${_("Constants")}</th>
<td>c, e, g, i, j, k, pi, q, T</td>
<td>20*c<br />
418*T
</td>
</tr>
<tr>
<th scope="row">${_("Affixes")}</th>
<td>Percent sign (%) and metric affixes (d, c, m, u, n, p, k, M, G, T)</td>
<td>20%<br />
20c<br />
418T
</td>
</tr>
<tr>
<th scope="row">${_("Basic functions")}</th>
<td>abs, exp, fact or factorial, ln, log2, log10, sqrt</td>
<td>abs(x+y)<br />
sqrt(x^2-y)
</td>
</tr>
<tr>
<th scope="row">${_("Trigonometric functions")}</th>
<td>sin, cos, tan, sec, csc, cot<br />
arcsin, sinh, arcsinh, etc.<br />
</td>
<td>sin(4x+y)<br />
arccsch(4x+y)
</td>
<td></td>
</tr>
<tr>
<th scope="row">${_("Scientific notation")}</th>
<td>10^ and the exponent</td>
<td>10^-9</td>
</tr>
<tr>
<th scope="row">${_("e notation")}</th>
<td>1e and the exponent</td>
<td>1e-9</td>
</tr>
</tbody>
</table>
</li>
</ul>
</div>
</div>
## Utility: Notes
% if is_edxnotes_enabled(course):
<%include file="/edxnotes/toggle_notes.html" args="course=course"/>
% endif
<input id="calculator_button" type="submit" title="${_('Calculate')}" value="=" aria-label="${_('Calculate')}" value="=" tabindex="-1" />
<input type="text" id="calculator_output" title="${_('Calculator Output Field')}" readonly tabindex="-1" />
</form>
</div>
</div>
## Utility: Calc
% if course.show_calculator:
<%include file="/calculator/toggle_calculator.html" />
% endif
</nav>
% endif
<%include file="../modal/accessible_confirm.html" />
......@@ -571,7 +571,7 @@ ${secondaryStateAction("close", "lock", _("Close"), _("Close"), _("Open"))}
</script>
</%def>
${secondaryAction("edit", "pencil", _("Edit"))}
${secondaryAction("edit", "pencil-square-o", _("Edit"))}
${secondaryAction("delete", "remove", _("Delete"))}
<script type="text/template" id="forum-actions">
......
<%! from django.utils.translation import ugettext as _ %>
<%! import json %>
<%namespace name='static' file='/static_content.html'/>
<%inherit file="/main.html" />
<%block name="bodyclass">view-student-notes is-in-course course</%block>
<%block name="pagetitle">${_("Student Notes")}</%block>
<%block name="headextra">
<%static:css group='style-course'/>
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='edxnotes'" />
<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>
% if notes:
<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...')}" required>
<button type="submit" class="search-notes-submit">
<i class="icon fa fa-search"></i>
<span class="sr">${_('Search')}</span>
</button>
</form>
</div>
% endif
</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>
% if notes:
<div class="ui-loading" tabindex="-1">
<span class="spin">
<i class="icon fa fa-refresh"></i>
</span>
<span class="copy">${_("Loading")}</span>
</div>
% else:
<section class="placeholder is-empty">
<article>
<h2 class="placeholder-title">${_('You have not made any notes in this course yet. Other students in this course are using notes to:')}</h2>
<div class="placeholder-copy">
<ul>
<li>${_("Mark a passage or concept so that it's easy to find later.")}</li>
<li>${_('Record thoughts about a specific passage or concept.')}</li>
<li>${_('Highlight important information to review later in the course or in future courses.')}</li>
</ul>
% if position is not None:
<div class="placeholder-cta student-notes-cta">
<p class="placeholder-cta-copy">${_('Get started by making a note in something you just read, like {section_link}.').format(
section_link='<a href="{url}">{section_name}</a>'.format(
url=position['url'],
section_name=position['display_name'],
)
)}</p>
</div>
% endif
</div>
</article>
</section>
% endif
</section>
</div>
</div>
</section>
## Include Underscore templates
<%block name="header_extras">
% for template_name in ["note-item", "tab-item"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="edxnotes/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="js_extra">
% if notes:
<script type="text/javascript">
(function (require) {
require(['js/edxnotes/views/page_factory'], function (NotesPageFactory) {
var pageView = new NotesPageFactory({
notesList: ${notes},
debugMode: ${debug}
});
});
}).call(this, require || RequireJS.require);
</script>
% endif
</%block>
<div class="wrapper-note-excerpts">
<% if (message) { %>
<div class="note-excerpt" role="region" aria-label="<%- gettext('Highlighted text') %>">
<p class="note-excerpt-p"><%= message %>
<% if (show_link) { %>
<% if (is_expanded) { %>
<a href="#" class="note-excerpt-more-link"><%- gettext('Less') %></a>
<% } else { %>
<a href="#" class="note-excerpt-more-link"><%- gettext('More') %></a>
<% } %>
<% } %>
</p>
</div>
<% } %>
<% if (text) { %>
<ol class="note-comments" role="region" aria-label="<%- gettext('Note') %>">
<li class="note-comment">
<h3 class="note-comment-title"><%- gettext("You commented...") %></h3>
<p class="note-comment-p"><%= text %></p>
</li>
</ol>
<% } %>
</div>
<footer class="reference" role="complementary" aria-label="<%- gettext('References') %>">
<div class="wrapper-reference-content">
<h3 class="reference-title"><%- gettext("Noted in:") %></h3>
<% if (unit.url) { %>
<a class="reference-meta reference-unit-link" href="<%= unit.url %>#<%= id %>"><%- unit.display_name %></a>
<% } else { %>
<span class="reference-meta"><%- unit.display_name %></span>
<% } %>
<h3 class="reference-title"><%- gettext("Last Edited:") %></h3>
<span class="reference-meta reference-updated-date"><%- updated %></span>
</div>
</footer>
<% var hasIcon = icon ? 1 : 0; %>
<a class="tab-label <% if (hasIcon) { print('has-icon') } %>" href="#">
<% if (hasIcon) { %><i class="icon <%= icon %>"></i> <% } %><%- gettext(name) %>
</a>
<% if (is_closable) { %>
<a href="#" class="action-close">
<i class="icon fa fa-times-circle"></i>
<span class="sr"><%- gettext("Clear search results") %></span>
</a>
<% } %>
<%! import json %>
<%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %>
<%page args="course"/>
<%
edxnotes_visibility = course.edxnotes_visibility
edxnotes_visibility_url = reverse("edxnotes_visibility", kwargs={"course_id": course.id})
%>
<div class="wrapper-utility edx-notes-visibility">
<span class="action-toggle-message" aria-live="polite"></span>
<button class="utility-control utility-control-button action-toggle-notes is-disabled ${"is-active" if edxnotes_visibility else ""}" aria-pressed="${"true" if edxnotes_visibility else "false"}">
<i class="icon fa fa-pencil"></i>
% if edxnotes_visibility:
<span class="utility-control-label sr">${_("Hide notes")}</span>
% else:
<span class="utility-control-label sr">${_("Show notes")}</span>
% endif
</button>
</div>
<script type="text/javascript">
(function (require) {
require(['js/edxnotes/views/toggle_notes_factory'], function(ToggleNotesFactory) {
ToggleNotesFactory(${json.dumps(edxnotes_visibility)}, '${edxnotes_visibility_url}');
});
}).call(this, require || RequireJS.require);
</script>
......@@ -163,7 +163,6 @@
</div>
<script>window.baseUrl = "${settings.STATIC_URL}";</script>
% if not disable_courseware_js:
<%static:js group='application'/>
<%static:js group='module-js'/>
......
......@@ -380,6 +380,10 @@ if settings.COURSEWARE_ENABLED:
# Student account and profile
url(r'^account/', include('student_account.urls')),
url(r'^profile/', include('student_profile.urls')),
# Student Notes
url(r'^courses/{}/edxnotes'.format(settings.COURSE_ID_PATTERN),
include('edxnotes.urls'), name="edxnotes_endpoints"),
)
# allow course staff to change to student view of courseware
......
......@@ -74,6 +74,11 @@ class Env(object):
'youtube': {
'port': 9080,
'log': BOK_CHOY_LOG_DIR / "bok_choy_youtube.log",
},
'edxnotes': {
'port': 8042,
'log': BOK_CHOY_LOG_DIR / "bok_choy_edxnotes.log",
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment