diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 06fa45818e..b12fc47216 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 5062450cf5..df34ee2688 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -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): """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index e6c6d458cc..07a8a8cac1 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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): diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index f40e86f082..f8b4de4781 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -867,62 +867,100 @@ def grading_handler(request, course_key_string, grader_index=None): # pylint: disable=invalid-name -def _config_course_advanced_components(request, course_module): +def _add_tab(request, tab_type, course_module): """ - Check to see if the user instantiated any advanced components. This - is a hack that does the following : - 1) adds/removes the open ended panel tab to a course automatically - if the user has indicated that they want to edit the - combinedopendended or peergrading module - 2) adds/removes the notes panel tab to a course automatically if - the user has indicated that they want the notes module enabled in - their course + Adds tab to the course. """ - # TODO refactor the above into distinct advanced policy settings - filter_tabs = True # Exceptional conditions will pull this to False - if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components - tab_component_map = { - 'open_ended': OPEN_ENDED_COMPONENT_TYPES, - 'notes': NOTE_COMPONENT_TYPES, - } - # Check to see if the user instantiated any notes or open ended components - for tab_type in tab_component_map.keys(): - component_types = tab_component_map.get(tab_type) - found_ac_type = False - for ac_type in component_types: + # Add tab to the course if needed + changed, new_tabs = add_extra_panel_tab(tab_type, course_module) + # If a tab has been added to the course, then send the + # metadata along to CourseMetadata.update_from_json + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs': {'value': new_tabs}}) + # Indicate that tabs should not be filtered out of + # the metadata + return True + return False - # Check if the user has incorrectly failed to put the value in an iterable. - new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value'] - if hasattr(new_advanced_component_list, '__iter__'): - if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES: - # Add tab to the course if needed - changed, new_tabs = add_extra_panel_tab(tab_type, course_module) - # If a tab has been added to the course, then send the - # metadata along to CourseMetadata.update_from_json - if changed: - course_module.tabs = new_tabs - request.json.update({'tabs': {'value': new_tabs}}) - # Indicate that tabs should not be filtered out of - # the metadata - filter_tabs = False # Set this flag to avoid the tab removal code below. - found_ac_type = True # break - else: - # If not iterable, return immediately and let validation handle. - return +# pylint: disable=invalid-name +def _remove_tab(request, tab_type, course_module): + """ + Removes the tab from the course. + """ + changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs': {'value': new_tabs}}) + return True + return False - # If we did not find a module type in the advanced settings, - # we may need to remove the tab from the course. - if not found_ac_type: # Remove tab from the course if needed - changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) - if changed: - course_module.tabs = new_tabs - request.json.update({'tabs': {'value': new_tabs}}) - # Indicate that tabs should *not* be filtered out of - # the metadata - filter_tabs = False - return filter_tabs +def is_advanced_component_present(request, advanced_components): + """ + Return True when one of `advanced_components` is present in the request. + + raises TypeError + when request.ADVANCED_COMPONENT_POLICY_KEY is malformed (not iterable) + """ + if ADVANCED_COMPONENT_POLICY_KEY not in request.json: + return False + + new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value'] + for ac_type in advanced_components: + if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES: + return True + + +def is_field_value_true(request, field_list): + """ + Return True when one of field values is set to True by request + """ + return any([request.json.get(field, {}).get('value') for field in field_list]) + + +# pylint: disable=invalid-name +def _modify_tabs_to_components(request, course_module): + """ + Automatically adds/removes tabs if user indicated that they want + respective modules enabled in the course + + Return True when tab configuration has been modified. + """ + tab_component_map = { + # 'tab_type': (check_function, list_of_checked_components_or_values), + + # open ended tab by combinedopendended or peergrading module + 'open_ended': (is_advanced_component_present, OPEN_ENDED_COMPONENT_TYPES), + # notes tab + 'notes': (is_advanced_component_present, NOTE_COMPONENT_TYPES), + # student notes tab + 'edxnotes': (is_field_value_true, ['edxnotes']) + } + + tabs_changed = False + for tab_type in tab_component_map.keys(): + check, component_types = tab_component_map[tab_type] + try: + tab_enabled = check(request, component_types) + except TypeError: + # user has failed to put iterable value into advanced component list. + # return immediately and let validation handle. + return + + if tab_enabled: + # check passed, some of this component_types are present, adding tab + if _add_tab(request, tab_type, course_module): + # tab indeed was added, the change needs to propagate + tabs_changed = True + else: + # the tab should not be present (anymore) + if _remove_tab(request, tab_type, course_module): + # tab indeed was removed, the change needs to propagate + tabs_changed = True + + return tabs_changed @login_required @@ -954,8 +992,8 @@ def advanced_settings_handler(request, course_key_string): return JsonResponse(CourseMetadata.fetch(course_module)) else: try: - # Whether or not to filter the tabs key out of the settings metadata - filter_tabs = _config_course_advanced_components(request, course_module) + # do not process tabs unless they were modified according to course metadata + filter_tabs = not _modify_tabs_to_components(request, course_module) # validate data formats and update is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json( diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 052ecfeb57..23320d1580 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -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 diff --git a/cms/envs/common.py b/cms/envs/common.py index 19d6b55b2d..718324ae53 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 diff --git a/cms/envs/test.py b/cms/envs/test.py index cf77fd0115..8a10f9761f 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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 diff --git a/cms/static/js/factories/base.js b/cms/static/js/factories/base.js index 49671bb8ec..b33a8123fa 100644 --- a/cms/static/js/factories/base.js +++ b/cms/static/js/factories/base.js @@ -1,2 +1,2 @@ -define(['js/base', 'coffee/src/main', 'coffee/src/logger', 'datepair', 'accessibility', +define(['js/base', 'coffee/src/main', 'js/src/logger', 'datepair', 'accessibility', 'ieshim', 'tooltip_manager']); diff --git a/cms/static/require-config.js b/cms/static/require-config.js index 0c5646d037..7a3a2d58a5 100644 --- a/cms/static/require-config.js +++ b/cms/static/require-config.js @@ -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"] }, diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py new file mode 100644 index 0000000000..3e697f51e9 --- /dev/null +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -0,0 +1,330 @@ +""" +Stub implementation of EdxNotes for acceptance tests +""" + +import json +import re +from uuid import uuid4 +from datetime import datetime +from copy import deepcopy + +from .http import StubHttpRequestHandler, StubHttpService + + +# pylint: disable=invalid-name +class StubEdxNotesServiceHandler(StubHttpRequestHandler): + """ + Handler for EdxNotes requests. + """ + URL_HANDLERS = { + "GET": { + "/api/v1/annotations$": "_collection", + "/api/v1/annotations/(?P[0-9A-Fa-f]+)$": "_read", + "/api/v1/search$": "_search", + }, + "POST": { + "/api/v1/annotations$": "_create", + "/create_notes": "_create_notes", + }, + "PUT": { + "/api/v1/annotations/(?P[0-9A-Fa-f]+)$": "_update", + "/cleanup$": "_cleanup", + }, + "DELETE": { + "/api/v1/annotations/(?P[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()] diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py index 80d2a0e70b..9d44af24f9 100644 --- a/common/djangoapps/terrain/stubs/http.py +++ b/common/djangoapps/terrain/stubs/http.py @@ -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) diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py index 0a60d42539..95b8e54d94 100644 --- a/common/djangoapps/terrain/stubs/start.py +++ b/common/djangoapps/terrain/stubs/start.py @@ -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 diff --git a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py new file mode 100644 index 0000000000..b75411397f --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py @@ -0,0 +1,189 @@ +""" +Unit tests for stub EdxNotes implementation. +""" + +import json +import unittest +import requests +from uuid import uuid4 +from ..edxnotes import StubEdxNotesService + + +class StubEdxNotesServiceTest(unittest.TestCase): + """ + Test cases for the stub EdxNotes service. + """ + def setUp(self): + """ + Start the stub server. + """ + self.server = StubEdxNotesService() + dummy_notes = self._get_dummy_notes(count=2) + self.server.add_notes(dummy_notes) + self.addCleanup(self.server.shutdown) + + def _get_dummy_notes(self, count=1): + """ + Returns a list of dummy notes. + """ + return [self._get_dummy_note() for i in xrange(count)] # pylint: disable=unused-variable + + def _get_dummy_note(self): + """ + Returns a single dummy note. + """ + nid = uuid4().hex + return { + "id": nid, + "created": "2014-10-31T10:05:00.000000", + "updated": "2014-10-31T10:50:00.101010", + "user": "dummy-user-id", + "usage_id": "dummy-usage-id", + "course_id": "dummy-course-id", + "text": "dummy note text " + nid, + "quote": "dummy note quote", + "ranges": [ + { + "start": "/p[1]", + "end": "/p[1]", + "startOffset": 0, + "endOffset": 10, + } + ], + } + + def test_note_create(self): + dummy_note = { + "user": "dummy-user-id", + "usage_id": "dummy-usage-id", + "course_id": "dummy-course-id", + "text": "dummy note text", + "quote": "dummy note quote", + "ranges": [ + { + "start": "/p[1]", + "end": "/p[1]", + "startOffset": 0, + "endOffset": 10, + } + ], + } + response = requests.post(self._get_url("api/v1/annotations"), data=json.dumps(dummy_note)) + self.assertTrue(response.ok) + response_content = response.json() + self.assertIn("id", response_content) + self.assertIn("created", response_content) + self.assertIn("updated", response_content) + self.assertIn("annotator_schema_version", response_content) + self.assertDictContainsSubset(dummy_note, response_content) + + def test_note_read(self): + notes = self._get_notes() + for note in notes: + response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) + self.assertTrue(response.ok) + self.assertDictEqual(note, response.json()) + + response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) + self.assertEqual(response.status_code, 404) + + def test_note_update(self): + notes = self._get_notes() + for note in notes: + response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) + self.assertTrue(response.ok) + self.assertDictEqual(note, response.json()) + + response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) + self.assertEqual(response.status_code, 404) + + def test_search(self): + response = requests.get(self._get_url("api/v1/search"), params={ + "user": "dummy-user-id", + "usage_id": "dummy-usage-id", + "course_id": "dummy-course-id", + }) + notes = self._get_notes() + self.assertTrue(response.ok) + self.assertDictEqual({"total": 2, "rows": notes}, response.json()) + + response = requests.get(self._get_url("api/v1/search")) + self.assertEqual(response.status_code, 400) + + def test_delete(self): + notes = self._get_notes() + response = requests.delete(self._get_url("api/v1/annotations/does_not_exist")) + self.assertEqual(response.status_code, 404) + + for note in notes: + response = requests.delete(self._get_url("api/v1/annotations/" + note["id"])) + self.assertEqual(response.status_code, 204) + remaining_notes = self.server.get_notes() + self.assertNotIn(note["id"], [note["id"] for note in remaining_notes]) + + self.assertEqual(len(remaining_notes), 0) + + def test_update(self): + note = self._get_notes()[0] + response = requests.put(self._get_url("api/v1/annotations/" + note["id"]), data=json.dumps({ + "text": "new test text" + })) + self.assertEqual(response.status_code, 200) + + updated_note = self._get_notes()[0] + self.assertEqual("new test text", updated_note["text"]) + self.assertEqual(note["id"], updated_note["id"]) + self.assertItemsEqual(note, updated_note) + + response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) + self.assertEqual(response.status_code, 404) + + def test_notes_collection(self): + response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"}) + self.assertTrue(response.ok) + self.assertEqual(len(response.json()), 2) + + response = requests.get(self._get_url("api/v1/annotations")) + self.assertEqual(response.status_code, 400) + + def test_cleanup(self): + response = requests.put(self._get_url("cleanup")) + self.assertTrue(response.ok) + self.assertEqual(len(self.server.get_notes()), 0) + + def test_create_notes(self): + dummy_notes = self._get_dummy_notes(count=2) + response = requests.post(self._get_url("create_notes"), data=json.dumps(dummy_notes)) + self.assertTrue(response.ok) + self.assertEqual(len(self._get_notes()), 4) + + response = requests.post(self._get_url("create_notes")) + self.assertEqual(response.status_code, 400) + + def test_headers(self): + note = self._get_notes()[0] + response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) + self.assertTrue(response.ok) + self.assertEqual(response.headers.get("access-control-allow-origin"), "*") + + response = requests.options(self._get_url("api/v1/annotations/")) + self.assertTrue(response.ok) + self.assertEqual(response.headers.get("access-control-allow-origin"), "*") + self.assertEqual(response.headers.get("access-control-allow-methods"), "GET, POST, PUT, DELETE, OPTIONS") + self.assertIn("X-CSRFToken", response.headers.get("access-control-allow-headers")) + + def _get_notes(self): + """ + Return a list of notes from the stub EdxNotes service. + """ + notes = self.server.get_notes() + self.assertGreater(len(notes), 0, "Notes are empty.") + return notes + + def _get_url(self, path): + """ + Construt a URL to the stub EdxNotes service. + """ + return "http://127.0.0.1:{port}/{path}/".format( + port=self.server.port, path=path + ) diff --git a/common/lib/xmodule/xmodule/edxnotes_utils.py b/common/lib/xmodule/xmodule/edxnotes_utils.py new file mode 100644 index 0000000000..70324d6539 --- /dev/null +++ b/common/lib/xmodule/xmodule/edxnotes_utils.py @@ -0,0 +1,15 @@ +""" +Utilities related to edXNotes. +""" +import sys + + +def edxnotes(cls): + """ + Conditional decorator that loads edxnotes only when they exist. + """ + if "edxnotes" in sys.modules: + from edxnotes.decorators import edxnotes as notes # pylint: disable=import-error + return notes(cls) + else: + return cls diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 7a81430918..acea5a3e55 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -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 """ diff --git a/common/lib/xmodule/xmodule/js/js_test.yml b/common/lib/xmodule/xmodule/js/js_test.yml index 95d4df3e3f..6af10abaa5 100644 --- a/common/lib/xmodule/xmodule/js/js_test.yml +++ b/common/lib/xmodule/xmodule/js/js_test.yml @@ -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 diff --git a/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee index b2a1409d4f..80910ddc95 100644 --- a/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee @@ -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() diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 3ec2f96dbd..ad3c5eb2c0 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -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): diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index b5d64a6a6e..1e960f3d69 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_tabs.py b/common/lib/xmodule/xmodule/tests/test_tabs.py index 7e1ed5964b..f912d41495 100644 --- a/common/lib/xmodule/xmodule/tests/test_tabs.py +++ b/common/lib/xmodule/xmodule/tests/test_tabs.py @@ -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 diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee index c39abffb26..1a4bd91452 100644 --- a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee +++ b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee @@ -546,7 +546,7 @@ browser and pasting the output. When that file changes, this one should be rege
  • Edit - +
  • diff --git a/common/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee deleted file mode 100644 index 69631170c9..0000000000 --- a/common/static/coffee/spec/logger_spec.coffee +++ /dev/null @@ -1,35 +0,0 @@ -describe 'Logger', -> - it 'expose window.log_event', -> - expect(window.log_event).toBe Logger.log - - describe 'log', -> - it 'send a request to log event', -> - spyOn jQuery, 'postWithPrefix' - Logger.log 'example', 'data' - expect(jQuery.postWithPrefix).toHaveBeenCalledWith '/event', - event_type: 'example' - event: '"data"' - page: window.location.href - - # Broken with commit 9f75e64? Skipping for now. - xdescribe 'bind', -> - beforeEach -> - Logger.bind() - Courseware.prefix = '/6002x' - - afterEach -> - window.onunload = null - - it 'bind the onunload event', -> - expect(window.onunload).toEqual jasmine.any(Function) - - it 'send a request to log event', -> - spyOn($, 'ajax') - window.onunload() - expect($.ajax).toHaveBeenCalledWith - url: "#{Courseware.prefix}/event", - data: - event_type: 'page_close' - event: '' - page: window.location.href - async: false diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee deleted file mode 100644 index 5a13ca8264..0000000000 --- a/common/static/coffee/src/logger.coffee +++ /dev/null @@ -1,48 +0,0 @@ -class @Logger - - # listeners[event_type][element] -> list of callbacks - listeners = {} - @log: (event_type, data, element = null) -> - # Check to see if we're listening for the event type. - if event_type of listeners - # Cool. Do the elements also match? - # null element in the listener dictionary means any element will do. - # null element in the @log call means we don't know the element name. - if null of listeners[event_type] - # Make the callbacks. - for callback in listeners[event_type][null] - callback(event_type, data, element) - else if element of listeners[event_type] - for callback in listeners[event_type][element] - callback(event_type, data, element) - - # Regardless of whether any callbacks were made, log this event. - $.postWithPrefix '/event', - event_type: event_type - event: JSON.stringify(data) - page: window.location.href - - @listen: (event_type, element, callback) -> - # Add a listener. If you want any element to trigger this listener, - # do element = null - if event_type not of listeners - listeners[event_type] = {} - if element not of listeners[event_type] - listeners[event_type][element] = [callback] - else - listeners[event_type][element].push callback - - @bind: -> - window.onunload = -> - $.ajaxWithPrefix - url: "/event" - data: - event_type: 'page_close' - event: '' - page: window.location.href - async: false - - -# log_event exists for compatibility reasons -# and will soon be deprecated. -@log_event = Logger.log diff --git a/common/static/css/vendor/edxnotes/annotator.min.css b/common/static/css/vendor/edxnotes/annotator.min.css new file mode 100644 index 0000000000..ec74509125 --- /dev/null +++ b/common/static/css/vendor/edxnotes/annotator.min.css @@ -0,0 +1,2 @@ +.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} + diff --git a/common/static/js/spec/logger_spec.js b/common/static/js/spec/logger_spec.js new file mode 100644 index 0000000000..37e41c7a74 --- /dev/null +++ b/common/static/js/spec/logger_spec.js @@ -0,0 +1,108 @@ +(function() { + 'use strict'; + describe('Logger', function() { + it('expose window.log_event', function() { + expect(window.log_event).toBe(Logger.log); + }); + + describe('log', function() { + it('can send a request to log event', function() { + spyOn(jQuery, 'ajaxWithPrefix'); + Logger.log('example', 'data'); + expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({ + url: '/event', + type: 'POST', + data: { + event_type: 'example', + event: '"data"', + page: window.location.href + }, + async: true + }); + }); + + it('can send a request with custom options to log event', function() { + spyOn(jQuery, 'ajaxWithPrefix'); + Logger.log('example', 'data', null, {type: 'GET', async: false}); + expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({ + url: '/event', + type: 'GET', + data: { + event_type: 'example', + event: '"data"', + page: window.location.href + }, + async: false + }); + }); + }); + + describe('listen', function() { + beforeEach(function () { + spyOn(jQuery, 'ajaxWithPrefix'); + this.callbacks = _.map(_.range(4), function () { + return jasmine.createSpy(); + }); + Logger.listen('example', null, this.callbacks[0]); + Logger.listen('example', null, this.callbacks[1]); + Logger.listen('example', 'element', this.callbacks[2]); + Logger.listen('new_event', null, this.callbacks[3]); + }); + + it('can listen events when the element name is unknown', function() { + Logger.log('example', 'data'); + expect(this.callbacks[0]).toHaveBeenCalledWith('example', 'data', null); + expect(this.callbacks[1]).toHaveBeenCalledWith('example', 'data', null); + expect(this.callbacks[2]).not.toHaveBeenCalled(); + expect(this.callbacks[3]).not.toHaveBeenCalled(); + }); + + it('can listen events when the element name is known', function() { + Logger.log('example', 'data', 'element'); + expect(this.callbacks[0]).not.toHaveBeenCalled(); + expect(this.callbacks[1]).not.toHaveBeenCalled(); + expect(this.callbacks[2]).toHaveBeenCalledWith('example', 'data', 'element'); + expect(this.callbacks[3]).not.toHaveBeenCalled(); + }); + }); + + describe('bind', function() { + beforeEach(function() { + this.initialPostWithPrefix = jQuery.postWithPrefix; + this.initialGetWithPrefix = jQuery.getWithPrefix; + this.initialAjaxWithPrefix = jQuery.ajaxWithPrefix; + this.prefix = '/6002x'; + AjaxPrefix.addAjaxPrefix($, _.bind(function () { + return this.prefix; + }, this)); + Logger.bind(); + }); + + afterEach(function() { + jQuery.postWithPrefix = this.initialPostWithPrefix; + jQuery.getWithPrefix = this.initialGetWithPrefix; + jQuery.ajaxWithPrefix = this.initialAjaxWithPrefix; + window.onunload = null; + }); + + it('can bind the onunload event', function() { + expect(window.onunload).toEqual(jasmine.any(Function)); + }); + + it('can send a request to log event', function() { + spyOn(jQuery, 'ajax'); + window.onunload(); + expect(jQuery.ajax).toHaveBeenCalledWith({ + url: this.prefix + '/event', + type: 'GET', + data: { + event_type: 'page_close', + event: '', + page: window.location.href + }, + async: false + }); + }); + }); + }); +}).call(this); diff --git a/common/static/js/src/logger.js b/common/static/js/src/logger.js new file mode 100644 index 0000000000..2495358e45 --- /dev/null +++ b/common/static/js/src/logger.js @@ -0,0 +1,82 @@ +;(function() { + 'use strict'; + var Logger = (function() { + // listeners[event_type][element] -> list of callbacks + var listeners = {}, + sendRequest, has; + + sendRequest = function(data, options) { + var request = $.ajaxWithPrefix ? $.ajaxWithPrefix : $.ajax; + + options = $.extend(true, { + 'url': '/event', + 'type': 'POST', + 'data': data, + 'async': true + }, options); + return request(options); + }; + + has = function(object, propertyName) { + return {}.hasOwnProperty.call(object, propertyName); + }; + + return { + /** + * Emits an event. + */ + log: function(eventType, data, element, requestOptions) { + var callbacks; + + if (!element) { + // null element in the listener dictionary means any element will do. + // null element in the Logger.log call means we don't know the element name. + element = null; + } + // Check to see if we're listening for the event type. + if (has(listeners, eventType)) { + if (has(listeners[eventType], element)) { + // Make the callbacks. + callbacks = listeners[eventType][element]; + $.each(callbacks, function(index, callback) { + callback(eventType, data, element); + }); + } + } + // Regardless of whether any callbacks were made, log this event. + return sendRequest({ + 'event_type': eventType, + 'event': JSON.stringify(data), + 'page': window.location.href + }, requestOptions); + }, + + /** + * Adds a listener. If you want any element to trigger this listener, + * do element = null + */ + listen: function(eventType, element, callback) { + listeners[eventType] = listeners[eventType] || {}; + listeners[eventType][element] = listeners[eventType][element] || []; + listeners[eventType][element].push(callback); + }, + + /** + * Binds `page_close` event. + */ + bind: function() { + window.onunload = function() { + sendRequest({ + event_type: 'page_close', + event: '', + page: window.location.href + }, {type: 'GET', async: false}); + }; + } + }; + }()); + + this.Logger = Logger; + // log_event exists for compatibility reasons and will soon be deprecated. + this.log_event = Logger.log; +}).call(this); diff --git a/common/static/js/vendor/edxnotes/annotator-full.min.js b/common/static/js/vendor/edxnotes/annotator-full.min.js new file mode 100644 index 0000000000..2f8597096b --- /dev/null +++ b/common/static/js/vendor/edxnotes/annotator-full.min.js @@ -0,0 +1,26 @@ +;(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/g,">").replace(/"/g,""")};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:'
    ",wrapper:'
    '};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(""+_t("No Comment")+"")}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=$('').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=$("");_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 diff --git a/common/test/acceptance/fixtures/__init__.py b/common/test/acceptance/fixtures/__init__.py index f4a6d18398..f104adcad9 100644 --- a/common/test/acceptance/fixtures/__init__.py +++ b/common/test/acceptance/fixtures/__init__.py @@ -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') diff --git a/common/test/acceptance/fixtures/edxnotes.py b/common/test/acceptance/fixtures/edxnotes.py new file mode 100644 index 0000000000..e64c40abec --- /dev/null +++ b/common/test/acceptance/fixtures/edxnotes.py @@ -0,0 +1,76 @@ +""" +Tools for creating edxnotes content fixture data. +""" + +import json +import factory +import requests + +from . import EDXNOTES_STUB_URL + + +class Range(factory.Factory): + FACTORY_FOR = dict + start = "/div[1]/p[1]" + end = "/div[1]/p[1]" + startOffset = 0 + endOffset = 8 + + +class Note(factory.Factory): + FACTORY_FOR = dict + user = "dummy-user" + usage_id = "dummy-usage-id" + course_id = "dummy-course-id" + text = "dummy note text" + quote = "dummy note quote" + ranges = [Range()] + + +class EdxNotesFixtureError(Exception): + """ + Error occurred while installing a edxnote fixture. + """ + pass + + +class EdxNotesFixture(object): + notes = [] + + def create_notes(self, notes_list): + self.notes = notes_list + return self + + def install(self): + """ + Push the data to the stub EdxNotes service. + """ + response = requests.post( + '{}/create_notes'.format(EDXNOTES_STUB_URL), + data=json.dumps(self.notes) + ) + + if not response.ok: + raise EdxNotesFixtureError( + "Could not create notes {0}. Status was {1}".format( + json.dumps(self.notes), response.status_code + ) + ) + + return self + + def cleanup(self): + """ + Cleanup the stub EdxNotes service. + """ + self.notes = [] + response = requests.put('{}/cleanup'.format(EDXNOTES_STUB_URL)) + + if not response.ok: + raise EdxNotesFixtureError( + "Could not cleanup EdxNotes service {0}. Status was {1}".format( + json.dumps(self.notes), response.status_code + ) + ) + + return self diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index ed203164dc..510c19b9be 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -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): """ diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py new file mode 100644 index 0000000000..e4c6cadaff --- /dev/null +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -0,0 +1,540 @@ +from bok_choy.page_object import PageObject, PageLoadError, unguarded +from bok_choy.promise import BrokenPromise +from .course_page import CoursePage +from ...tests.helpers import disable_animations +from selenium.webdriver.common.action_chains import ActionChains + + +class NoteChild(PageObject): + url = None + BODY_SELECTOR = None + + def __init__(self, browser, item_id): + super(NoteChild, self).__init__(browser) + self.item_id = item_id + + def is_browser_on_page(self): + return self.q(css="{}#{}".format(self.BODY_SELECTOR, self.item_id)).present + + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular `NoteChild` context + """ + return "{}#{} {}".format( + self.BODY_SELECTOR, + self.item_id, + selector, + ) + + def _get_element_text(self, selector): + element = self.q(css=self._bounded_selector(selector)).first + if element: + return element.text[0] + else: + return None + + +class EdxNotesPageGroup(NoteChild): + """ + Helper class that works with note groups on Note page of the course. + """ + BODY_SELECTOR = ".note-group" + + @property + def title(self): + return self._get_element_text(".course-title") + + @property + def subtitles(self): + return [section.title for section in self.children] + + @property + def children(self): + children = self.q(css=self._bounded_selector('.note-section')) + return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children] + + +class EdxNotesPageSection(NoteChild): + """ + Helper class that works with note sections on Note page of the course. + """ + BODY_SELECTOR = ".note-section" + + @property + def title(self): + return self._get_element_text(".course-subtitle") + + @property + def children(self): + children = self.q(css=self._bounded_selector('.note')) + return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children] + + @property + def notes(self): + return [section.text for section in self.children] + + +class EdxNotesPageItem(NoteChild): + """ + Helper class that works with note items on Note page of the course. + """ + BODY_SELECTOR = ".note" + UNIT_LINK_SELECTOR = "a.reference-unit-link" + + def go_to_unit(self, unit_page=None): + self.q(css=self._bounded_selector(self.UNIT_LINK_SELECTOR)).click() + if unit_page is not None: + unit_page.wait_for_page() + + @property + def unit_name(self): + return self._get_element_text(self.UNIT_LINK_SELECTOR) + + @property + def text(self): + return self._get_element_text(".note-comment-p") + + @property + def quote(self): + return self._get_element_text(".note-excerpt") + + @property + def time_updated(self): + return self._get_element_text(".reference-updated-date") + + +class EdxNotesPageView(PageObject): + """ + Base class for EdxNotes views: Recent Activity, Location in Course, Search Results. + """ + url = None + BODY_SELECTOR = ".tab-panel" + TAB_SELECTOR = ".tab" + CHILD_SELECTOR = ".note" + CHILD_CLASS = EdxNotesPageItem + + @unguarded + def visit(self): + """ + Open the page containing this page object in the browser. + + Raises: + PageLoadError: The page did not load successfully. + + Returns: + PageObject + """ + self.q(css=self.TAB_SELECTOR).first.click() + try: + return self.wait_for_page() + except (BrokenPromise): + raise PageLoadError("Timed out waiting to load page '{!r}'".format(self)) + + def is_browser_on_page(self): + return all([ + self.q(css="{}".format(self.BODY_SELECTOR)).present, + self.q(css="{}.is-active".format(self.TAB_SELECTOR)).present, + not self.q(css=".ui-loading").visible, + ]) + + @property + def is_closable(self): + """ + Indicates if tab is closable or not. + """ + return self.q(css="{} .action-close".format(self.TAB_SELECTOR)).present + + def close(self): + """ + Closes the tab. + """ + self.q(css="{} .action-close".format(self.TAB_SELECTOR)).first.click() + + @property + def children(self): + """ + Returns all notes on the page. + """ + children = self.q(css=self.CHILD_SELECTOR) + return [self.CHILD_CLASS(self.browser, child.get_attribute("id")) for child in children] + + +class RecentActivityView(EdxNotesPageView): + """ + Helper class for Recent Activity view. + """ + BODY_SELECTOR = "#recent-panel" + TAB_SELECTOR = ".tab#view-recent-activity" + + +class CourseStructureView(EdxNotesPageView): + """ + Helper class for Location in Course view. + """ + BODY_SELECTOR = "#structure-panel" + TAB_SELECTOR = ".tab#view-course-structure" + CHILD_SELECTOR = ".note-group" + CHILD_CLASS = EdxNotesPageGroup + + +class SearchResultsView(EdxNotesPageView): + """ + Helper class for Search Results view. + """ + BODY_SELECTOR = "#search-results-panel" + TAB_SELECTOR = ".tab#view-search-results" + + +class EdxNotesPage(CoursePage): + """ + EdxNotes page. + """ + url_path = "edxnotes/" + MAPPING = { + "recent": RecentActivityView, + "structure": CourseStructureView, + "search": SearchResultsView, + } + + def __init__(self, *args, **kwargs): + super(EdxNotesPage, self).__init__(*args, **kwargs) + self.current_view = self.MAPPING["recent"](self.browser) + + def is_browser_on_page(self): + return self.q(css=".wrapper-student-notes").present + + def switch_to_tab(self, tab_name): + """ + Switches to the appropriate tab `tab_name(str)`. + """ + self.current_view = self.MAPPING[tab_name](self.browser) + self.current_view.visit() + + def close_tab(self, tab_name): + """ + Closes the tab `tab_name(str)`. + """ + self.current_view.close() + self.current_view = self.MAPPING["recent"](self.browser) + + def search(self, text): + """ + Runs search with `text(str)` query. + """ + self.q(css="#search-notes-form #search-notes-input").first.fill(text) + self.q(css='#search-notes-form .search-notes-submit').first.click() + # Frontend will automatically switch to Search results tab when search + # is running, so the view also needs to be changed. + self.current_view = self.MAPPING["search"](self.browser) + if text.strip(): + self.current_view.wait_for_page() + + @property + def tabs(self): + """ + Returns all tabs on the page. + """ + tabs = self.q(css=".tabs .tab-label") + if tabs: + return map(lambda x: x.replace("Current tab\n", ""), tabs.text) + else: + return None + + @property + def is_error_visible(self): + """ + Indicates whether error message is visible or not. + """ + return self.q(css=".inline-error").visible + + @property + def error_text(self): + """ + Returns error message. + """ + element = self.q(css=".inline-error").first + if element and self.is_error_visible: + return element.text[0] + else: + return None + + @property + def notes(self): + """ + Returns all notes on the page. + """ + children = self.q(css='.note') + return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children] + + @property + def groups(self): + """ + Returns all groups on the page. + """ + children = self.q(css='.note-group') + return [EdxNotesPageGroup(self.browser, child.get_attribute("id")) for child in children] + + @property + def sections(self): + """ + Returns all sections on the page. + """ + children = self.q(css='.note-section') + return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children] + + @property + def no_content_text(self): + """ + Returns no content message. + """ + element = self.q(css=".is-empty").first + if element: + return element.text[0] + else: + return None + + +class EdxNotesUnitPage(CoursePage): + """ + Page for the Unit with EdxNotes. + """ + url_path = "courseware/" + + def is_browser_on_page(self): + return self.q(css="body.courseware .edx-notes-wrapper").present + + def move_mouse_to(self, selector): + """ + Moves mouse to the element that matches `selector(str)`. + """ + body = self.q(css=selector)[0] + ActionChains(self.browser).move_to_element(body).release().perform() + return self + + def click(self, selector): + """ + Clicks on the element that matches `selector(str)`. + """ + self.q(css=selector).first.click() + return self + + def toggle_visibility(self): + """ + Clicks on the "Show notes" checkbox. + """ + self.q(css=".action-toggle-notes").first.click() + return self + + @property + def components(self): + """ + Returns a list of annotatable components. + """ + components = self.q(css=".edx-notes-wrapper") + return [AnnotatableComponent(self.browser, component.get_attribute("id")) for component in components] + + @property + def notes(self): + """ + Returns a list of notes for the page. + """ + notes = [] + for component in self.components: + notes.extend(component.notes) + return notes + + def refresh(self): + """ + Refreshes the page and returns a list of annotatable components. + """ + self.browser.refresh() + return self.components + + +class AnnotatableComponent(NoteChild): + """ + Helper class that works with annotatable components. + """ + BODY_SELECTOR = ".edx-notes-wrapper" + + @property + def notes(self): + """ + Returns a list of notes for the component. + """ + notes = self.q(css=self._bounded_selector(".annotator-hl")) + return [EdxNoteHighlight(self.browser, note, self.item_id) for note in notes] + + def create_note(self, selector=".annotate-id"): + """ + Create the note by the selector, return a context manager that will + show and save the note popup. + """ + for element in self.q(css=self._bounded_selector(selector)): + note = EdxNoteHighlight(self.browser, element, self.item_id) + note.select_and_click_adder() + yield note + note.save() + + def edit_note(self, selector=".annotator-hl"): + """ + Edit the note by the selector, return a context manager that will + show and save the note popup. + """ + for element in self.q(css=self._bounded_selector(selector)): + note = EdxNoteHighlight(self.browser, element, self.item_id) + note.show().edit() + yield note + note.save() + + def remove_note(self, selector=".annotator-hl"): + """ + Removes the note by the selector. + """ + for element in self.q(css=self._bounded_selector(selector)): + note = EdxNoteHighlight(self.browser, element, self.item_id) + note.show().remove() + + +class EdxNoteHighlight(NoteChild): + """ + Helper class that works with notes. + """ + BODY_SELECTOR = "" + ADDER_SELECTOR = ".annotator-adder" + VIEWER_SELECTOR = ".annotator-viewer" + EDITOR_SELECTOR = ".annotator-editor" + + def __init__(self, browser, element, parent_id): + super(EdxNoteHighlight, self).__init__(browser, parent_id) + self.element = element + self.item_id = parent_id + disable_animations(self) + + @property + def is_visible(self): + """ + Returns True if the note is visible. + """ + viewer_is_visible = self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).visible + editor_is_visible = self.q(css=self._bounded_selector(self.EDITOR_SELECTOR)).visible + return viewer_is_visible or editor_is_visible + + def wait_for_adder_visibility(self): + """ + Waiting for visibility of note adder button. + """ + self.wait_for_element_visibility( + self._bounded_selector(self.ADDER_SELECTOR), "Adder is visible." + ) + + def wait_for_viewer_visibility(self): + """ + Waiting for visibility of note viewer. + """ + self.wait_for_element_visibility( + self._bounded_selector(self.VIEWER_SELECTOR), "Note Viewer is visible." + ) + + def wait_for_editor_visibility(self): + """ + Waiting for visibility of note editor. + """ + self.wait_for_element_visibility( + self._bounded_selector(self.EDITOR_SELECTOR), "Note Editor is visible." + ) + + def wait_for_notes_invisibility(self, text="Notes are hidden"): + """ + Waiting for invisibility of all notes. + """ + selector = self._bounded_selector(".annotator-outer") + self.wait_for_element_invisibility(selector, text) + + def select_and_click_adder(self): + """ + Creates selection for the element and clicks `add note` button. + """ + ActionChains(self.browser).double_click(self.element).release().perform() + self.wait_for_adder_visibility() + self.q(css=self._bounded_selector(self.ADDER_SELECTOR)).first.click() + self.wait_for_editor_visibility() + return self + + def click_on_highlight(self): + """ + Clicks on the highlighted text. + """ + ActionChains(self.browser).move_to_element(self.element).click().release().perform() + return self + + def click_on_viewer(self): + """ + Clicks on the note viewer. + """ + self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).first.click() + return self + + def show(self): + """ + Hover over highlighted text -> shows note. + """ + ActionChains(self.browser).move_to_element(self.element).release().perform() + self.wait_for_viewer_visibility() + return self + + def cancel(self): + """ + Clicks cancel button. + """ + self.q(css=self._bounded_selector(".annotator-cancel")).first.click() + self.wait_for_notes_invisibility("Note is canceled.") + return self + + def save(self): + """ + Clicks save button. + """ + self.q(css=self._bounded_selector(".annotator-save")).first.click() + self.wait_for_notes_invisibility("Note is saved.") + self.wait_for_ajax() + return self + + def remove(self): + """ + Clicks delete button. + """ + self.q(css=self._bounded_selector(".annotator-delete")).first.click() + self.wait_for_notes_invisibility("Note is removed.") + self.wait_for_ajax() + return self + + def edit(self): + """ + Clicks edit button. + """ + self.q(css=self._bounded_selector(".annotator-edit")).first.click() + self.wait_for_editor_visibility() + return self + + @property + def text(self): + """ + Returns text of the note. + """ + self.show() + element = self.q(css=self._bounded_selector(".annotator-annotation > div")) + if element: + text = element.text[0].strip() + else: + text = None + self.q(css=("body")).first.click() + self.wait_for_notes_invisibility() + return text + + @text.setter + def text(self, value): + """ + Sets text for the note. + """ + self.q(css=self._bounded_selector(".annotator-item textarea")).first.fill(value) diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py index 7b833dde22..147d698299 100644 --- a/common/test/acceptance/tests/helpers.py +++ b/common/test/acceptance/tests/helpers.py @@ -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. diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py new file mode 100644 index 0000000000..206662767b --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -0,0 +1,775 @@ +import os +from uuid import uuid4 +from datetime import datetime +from unittest import skipUnless +from ..helpers import UniqueCourseTest +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.course_nav import CourseNavPage +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.edxnotes import EdxNotesUnitPage, EdxNotesPage +from ...fixtures.edxnotes import EdxNotesFixture, Note, Range + + +@skipUnless(os.environ.get("FEATURE_EDXNOTES"), "Requires Student Notes feature to be enabled") +class EdxNotesTestMixin(UniqueCourseTest): + """ + Creates a course with initial data and contains useful helper methods. + """ + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(EdxNotesTestMixin, self).setUp() + self.courseware_page = CoursewarePage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.note_unit_page = EdxNotesUnitPage(self.browser, self.course_id) + self.notes_page = EdxNotesPage(self.browser, self.course_id) + + self.username = str(uuid4().hex)[:5] + self.email = "{}@email.com".format(self.username) + + self.selector = "annotate-id" + self.edxnotes_fixture = EdxNotesFixture() + self.course_fixture = CourseFixture( + self.course_info["org"], self.course_info["number"], + self.course_info["run"], self.course_info["display_name"] + ) + + self.course_fixture.add_advanced_settings({ + u"edxnotes": {u"value": True} + }) + + self.course_fixture.add_children( + XBlockFixtureDesc("chapter", "Test Section 1").add_children( + XBlockFixtureDesc("sequential", "Test Subsection 1").add_children( + XBlockFixtureDesc("vertical", "Test Unit 1").add_children( + XBlockFixtureDesc( + "html", + "Test HTML 1", + data=""" +

    Annotate this text!

    +

    Annotate this text

    + """.format(self.selector) + ), + XBlockFixtureDesc( + "html", + "Test HTML 2", + data="""

    Annotate this text!

    """.format(self.selector) + ), + ), + XBlockFixtureDesc("vertical", "Test Unit 2").add_children( + XBlockFixtureDesc( + "html", + "Test HTML 3", + data="""

    Annotate this text!

    """.format(self.selector) + ), + ), + ), + XBlockFixtureDesc("sequential", "Test Subsection 2").add_children( + XBlockFixtureDesc("vertical", "Test Unit 3").add_children( + XBlockFixtureDesc( + "html", + "Test HTML 4", + data=""" +

    Annotate this text!

    + """.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=""" +

    Annotate this text!

    + """.format(self.selector) + ), + XBlockFixtureDesc( + "html", + "Test HTML 6", + data="""

    Annotate this text!

    """.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) diff --git a/common/test/db_fixtures/edx-notes_client.json b/common/test/db_fixtures/edx-notes_client.json new file mode 100644 index 0000000000..cac1a40b8e --- /dev/null +++ b/common/test/db_fixtures/edx-notes_client.json @@ -0,0 +1,15 @@ +[ + { + "pk": 1, + "model": "oauth2.client", + "fields": { + "name": "edx-notes", + "url": "http://example.com/", + "client_type": 1, + "redirect_uri": "http://example.com/welcome", + "user": null, + "client_id": "22a9e15e3d3b115e4d43", + "client_secret": "7969f769a1fe21ecd6cf8a1c105f250f70a27131" + } + } +] diff --git a/lms/djangoapps/edxnotes/__init__.py b/lms/djangoapps/edxnotes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/edxnotes/decorators.py b/lms/djangoapps/edxnotes/decorators.py new file mode 100644 index 0000000000..aadeff8a12 --- /dev/null +++ b/lms/djangoapps/edxnotes/decorators.py @@ -0,0 +1,54 @@ +""" +Decorators related to edXNotes. +""" +from django.conf import settings +import json +from edxnotes.helpers import ( + get_endpoint, + get_id_token, + get_token_url, + generate_uid, + is_feature_enabled, +) +from edxmako.shortcuts import render_to_string + + +def edxnotes(cls): + """ + Decorator that makes components annotatable. + """ + original_get_html = cls.get_html + + def get_html(self, *args, **kwargs): + """ + Returns raw html for the component. + """ + is_studio = getattr(self.system, "is_author_mode", False) + course = self.descriptor.runtime.modulestore.get_course(self.runtime.course_id) + + # Must be disabled: + # - in Studio; + # - when Harvard Annotation Tool is enabled for the course; + # - when the feature flag or `edxnotes` setting of the course is set to False. + if is_studio or not is_feature_enabled(course): + return original_get_html(self, *args, **kwargs) + else: + return render_to_string("edxnotes_wrapper.html", { + "content": original_get_html(self, *args, **kwargs), + "uid": generate_uid(), + "edxnotes_visibility": json.dumps( + getattr(self, 'edxnotes_visibility', course.edxnotes_visibility) + ), + "params": { + # Use camelCase to name keys. + "usageId": unicode(self.scope_ids.usage_id).encode("utf-8"), + "courseId": unicode(self.runtime.course_id).encode("utf-8"), + "token": get_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)), + "tokenUrl": get_token_url(self.runtime.course_id), + "endpoint": get_endpoint(), + "debug": settings.DEBUG, + }, + }) + + cls.get_html = get_html + return cls diff --git a/lms/djangoapps/edxnotes/exceptions.py b/lms/djangoapps/edxnotes/exceptions.py new file mode 100644 index 0000000000..25ada954a2 --- /dev/null +++ b/lms/djangoapps/edxnotes/exceptions.py @@ -0,0 +1,17 @@ +""" +Exceptions related to EdxNotes. +""" + + +class EdxNotesParseError(Exception): + """ + An exception that is raised whenever we have issues with data parsing. + """ + pass + + +class EdxNotesServiceUnavailable(Exception): + """ + An exception that is raised whenever EdxNotes service is unavailable. + """ + pass diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py new file mode 100644 index 0000000000..e0586842c5 --- /dev/null +++ b/lms/djangoapps/edxnotes/helpers.py @@ -0,0 +1,401 @@ +""" +Helper methods related to EdxNotes. +""" +import json +import logging +import requests +from requests.exceptions import RequestException +from uuid import uuid4 +from json import JSONEncoder +from datetime import datetime +from courseware.access import has_access +from courseware.views import get_current_child +from django.conf import settings +from django.core.urlresolvers import reverse +from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import ugettext as _ + +from capa.util import sanitize_html +from student.models import anonymous_id_for_user +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from util.date_utils import get_default_time_display +from dateutil.parser import parse as dateutil_parse +from provider.oauth2.models import AccessToken, Client +import oauth2_provider.oidc as oidc +from provider.utils import now +from opaque_keys.edx.keys import UsageKey +from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable + +log = logging.getLogger(__name__) +HIGHLIGHT_TAG = "span" +HIGHLIGHT_CLASS = "note-highlight" + + +class NoteJSONEncoder(JSONEncoder): + """ + Custom JSON encoder that encode datetime objects to appropriate time strings. + """ + # pylint: disable=method-hidden + def default(self, obj): + if isinstance(obj, datetime): + return get_default_time_display(obj) + return json.JSONEncoder.default(self, obj) + + +def get_id_token(user): + """ + Generates JWT ID-Token, using or creating user's OAuth access token. + """ + try: + client = Client.objects.get(name="edx-notes") + except Client.DoesNotExist: + raise ImproperlyConfigured("OAuth2 Client with name 'edx-notes' is not present in the DB") + try: + access_token = AccessToken.objects.get( + client=client, + user=user, + expires__gt=now() + ) + except AccessToken.DoesNotExist: + access_token = AccessToken(client=client, user=user) + access_token.save() + + id_token = oidc.id_token(access_token) + secret = id_token.access_token.client.client_secret + return id_token.encode(secret) + + +def get_token_url(course_id): + """ + Returns token url for the course. + """ + return reverse("get_token", kwargs={ + "course_id": unicode(course_id), + }) + + +def send_request(user, course_id, path="", query_string=None): + """ + Sends a request with appropriate parameters and headers. + """ + url = get_endpoint(path) + params = { + "user": anonymous_id_for_user(user, None), + "course_id": unicode(course_id).encode("utf-8"), + } + + if query_string: + params.update({ + "text": query_string, + "highlight": True, + "highlight_tag": HIGHLIGHT_TAG, + "highlight_class": HIGHLIGHT_CLASS, + }) + + try: + response = requests.get( + url, + headers={ + "x-annotator-auth-token": get_id_token(user) + }, + params=params + ) + except RequestException: + raise EdxNotesServiceUnavailable(_("EdxNotes Service is unavailable. Please try again in a few minutes.")) + + return response + + +def get_parent_xblock(xblock): + """ + Returns the xblock that is the parent of the specified xblock, or None if it has no parent. + """ + # TODO: replace with xblock.get_parent() when it lands + store = modulestore() + if store.request_cache is not None: + parent_cache = store.request_cache.data.setdefault('edxnotes-parent-cache', {}) + else: + parent_cache = None + + locator = xblock.location + if parent_cache and unicode(locator) in parent_cache: + return parent_cache[unicode(locator)] + + parent_location = store.get_parent_location(locator) + + if parent_location is None: + return None + xblock = store.get_item(parent_location) + # .get_parent_location(locator) returns locators w/o branch and version + # and for uniformity we remove them from children locators + xblock.children = [child.for_branch(None) for child in xblock.children] + + if parent_cache is not None: + for child in xblock.children: + parent_cache[unicode(child)] = xblock + + return xblock + + +def get_parent_unit(xblock): + """ + Find vertical that is a unit, not just some container. + """ + while xblock: + xblock = get_parent_xblock(xblock) + if xblock is None: + return None + parent = get_parent_xblock(xblock) + if parent is None: + return None + if parent.category == 'sequential': + return xblock + + +def preprocess_collection(user, course, collection): + """ + Prepare `collection(notes_list)` provided by edx-notes-api + for rendering in a template: + add information about ancestor blocks, + convert "updated" to date + + Raises: + ItemNotFoundError - when appropriate module is not found. + """ + # pylint: disable=too-many-statements + + store = modulestore() + filtered_collection = list() + cache = {} + with store.bulk_operations(course.id): + for model in collection: + model.update({ + u"text": sanitize_html(model["text"]), + u"quote": sanitize_html(model["quote"]), + u"updated": dateutil_parse(model["updated"]), + }) + usage_id = model["usage_id"] + if usage_id in cache: + model.update(cache[usage_id]) + filtered_collection.append(model) + continue + + usage_key = UsageKey.from_string(usage_id) + # Add a course run if necessary. + usage_key = usage_key.replace(course_key=store.fill_in_run(usage_key.course_key)) + + try: + item = store.get_item(usage_key) + except ItemNotFoundError: + log.debug("Module not found: %s", usage_key) + continue + + if not has_access(user, "load", item, course_key=course.id): + log.debug("User %s does not have an access to %s", user, item) + continue + + unit = get_parent_unit(item) + if unit is None: + log.debug("Unit not found: %s", usage_key) + continue + + section = get_parent_xblock(unit) + if not section: + log.debug("Section not found: %s", usage_key) + continue + if section in cache: + usage_context = cache[section] + usage_context.update({ + "unit": get_module_context(course, unit), + }) + model.update(usage_context) + cache[usage_id] = cache[unit] = usage_context + filtered_collection.append(model) + continue + + chapter = get_parent_xblock(section) + if not chapter: + log.debug("Chapter not found: %s", usage_key) + continue + if chapter in cache: + usage_context = cache[chapter] + usage_context.update({ + "unit": get_module_context(course, unit), + "section": get_module_context(course, section), + }) + model.update(usage_context) + cache[usage_id] = cache[unit] = cache[section] = usage_context + filtered_collection.append(model) + continue + + usage_context = { + "unit": get_module_context(course, unit), + "section": get_module_context(course, section), + "chapter": get_module_context(course, chapter), + } + model.update(usage_context) + cache[usage_id] = cache[unit] = cache[section] = cache[chapter] = usage_context + filtered_collection.append(model) + + return filtered_collection + + +def get_module_context(course, item): + """ + Returns dispay_name and url for the parent module. + """ + item_dict = { + 'location': unicode(item.location), + 'display_name': item.display_name_with_default, + } + if item.category == 'chapter': + item_dict['index'] = get_index(item_dict['location'], course.children) + elif item.category == 'vertical': + section = get_parent_xblock(item) + chapter = get_parent_xblock(section) + # Position starts from 1, that's why we add 1. + position = get_index(unicode(item.location), section.children) + 1 + item_dict['url'] = reverse('courseware_position', kwargs={ + 'course_id': unicode(course.id), + 'chapter': chapter.url_name, + 'section': section.url_name, + 'position': position, + }) + if item.category in ('chapter', 'sequential'): + item_dict['children'] = [unicode(child) for child in item.children] + + return item_dict + + +def get_index(usage_key, children): + """ + Returns an index of the child with `usage_key`. + """ + children = [unicode(child) for child in children] + return children.index(usage_key) + + +def search(user, course, query_string): + """ + Returns search results for the `query_string(str)`. + """ + response = send_request(user, course.id, "search", query_string) + try: + content = json.loads(response.content) + collection = content["rows"] + except (ValueError, KeyError): + log.warning("invalid JSON: %s", response.content) + raise EdxNotesParseError(_("Server error. Please try again in a few minutes.")) + + content.update({ + "rows": preprocess_collection(user, course, collection) + }) + + return json.dumps(content, cls=NoteJSONEncoder) + + +def get_notes(user, course): + """ + Returns all notes for the user. + """ + response = send_request(user, course.id, "annotations") + try: + collection = json.loads(response.content) + except ValueError: + return None + + if not collection: + return None + + return json.dumps(preprocess_collection(user, course, collection), cls=NoteJSONEncoder) + + +def get_endpoint(path=""): + """ + Returns edx-notes-api endpoint. + """ + try: + url = settings.EDXNOTES_INTERFACE['url'] + if not url.endswith("/"): + url += "/" + + if path: + if path.startswith("/"): + path = path.lstrip("/") + if not path.endswith("/"): + path += "/" + + return url + path + except (AttributeError, KeyError): + raise ImproperlyConfigured(_("No endpoint was provided for EdxNotes.")) + + +def get_course_position(course_module): + """ + Return the user's current place in the course. + + If this is the user's first time, leads to COURSE/CHAPTER/SECTION. + If this isn't the users's first time, leads to COURSE/CHAPTER. + + If there is no current position in the course or chapter, then selects + the first child. + """ + urlargs = {'course_id': unicode(course_module.id)} + chapter = get_current_child(course_module, min_depth=1) + if chapter is None: + log.debug("No chapter found when loading current position in course") + return None + + urlargs['chapter'] = chapter.url_name + if course_module.position is not None: + return { + 'display_name': chapter.display_name_with_default, + 'url': reverse('courseware_chapter', kwargs=urlargs), + } + + # Relying on default of returning first child + section = get_current_child(chapter, min_depth=1) + if section is None: + log.debug("No section found when loading current position in course") + return None + + urlargs['section'] = section.url_name + return { + 'display_name': section.display_name_with_default, + 'url': reverse('courseware_section', kwargs=urlargs) + } + + +def generate_uid(): + """ + Generates unique id. + """ + return uuid4().int # pylint: disable=no-member + + +def is_feature_enabled(course): + """ + Returns True if Student Notes feature is enabled for the course, + False otherwise. + + In order for the application to be enabled it must be: + 1) enabled globally via FEATURES. + 2) present in the course tab configuration. + 3) Harvard Annotation Tool must be disabled for the course. + """ + return (settings.FEATURES.get("ENABLE_EDXNOTES") + and [t for t in course.tabs if t["type"] == "edxnotes"] # tab found + and not is_harvard_notes_enabled(course)) + + +def is_harvard_notes_enabled(course): + """ + Returns True if Harvard Annotation Tool is enabled for the course, + False otherwise. + + Checks for 'textannotation', 'imageannotation', 'videoannotation' in the list + of advanced modules of the course. + """ + modules = set(['textannotation', 'imageannotation', 'videoannotation']) + return bool(modules.intersection(course.advanced_modules)) diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py new file mode 100644 index 0000000000..fdd013812f --- /dev/null +++ b/lms/djangoapps/edxnotes/tests.py @@ -0,0 +1,975 @@ +""" +Tests for the EdxNotes app. +""" +import json +import jwt +from mock import patch, MagicMock +from unittest import skipUnless +from datetime import datetime +from edxmako.shortcuts import render_to_string +from edxnotes import helpers +from edxnotes.decorators import edxnotes +from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable +from django.conf import settings +from django.test import TestCase +from django.core.urlresolvers import reverse +from django.core.exceptions import ImproperlyConfigured +from oauth2_provider.tests.factories import ClientFactory +from provider.oauth2.models import Client +from xmodule.tabs import EdxNotesTab +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.django import modulestore +from courseware.model_data import FieldDataCache +from courseware.module_render import get_module_for_descriptor +from student.tests.factories import UserFactory + + +def enable_edxnotes_for_the_course(course, user_id): + """ + Enable EdxNotes for the course. + """ + course.tabs.append(EdxNotesTab()) + modulestore().update_item(course, user_id) + + +@edxnotes +class TestProblem(object): + """ + Test class (fake problem) decorated by edxnotes decorator. + + The purpose of this class is to imitate any problem. + """ + def __init__(self, course): + self.system = MagicMock(is_author_mode=False) + self.scope_ids = MagicMock(usage_id="test_usage_id") + self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx") + self.runtime = MagicMock(course_id=course.id, get_real_user=lambda anon_id: self.user) + self.descriptor = MagicMock() + self.descriptor.runtime.modulestore.get_course.return_value = course + + def get_html(self): + """ + Imitate get_html in module. + """ + return "original_get_html" + + +@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") +class EdxNotesDecoratorTest(TestCase): + """ + Tests for edxnotes decorator. + """ + + def setUp(self): + ClientFactory(name="edx-notes") + self.course = CourseFactory.create(edxnotes=True) + self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + self.client.login(username=self.user.username, password="edx") + self.problem = TestProblem(self.course) + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) + @patch("edxnotes.decorators.get_endpoint") + @patch("edxnotes.decorators.get_token_url") + @patch("edxnotes.decorators.get_id_token") + @patch("edxnotes.decorators.generate_uid") + def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_token_url, mock_get_endpoint): + """ + Tests if get_html is wrapped when feature flag is on and edxnotes are + enabled for the course. + """ + mock_generate_uid.return_value = "uid" + mock_get_id_token.return_value = "token" + mock_get_token_url.return_value = "/tokenUrl" + mock_get_endpoint.return_value = "/endpoint" + enable_edxnotes_for_the_course(self.course, self.user.id) + expected_context = { + "content": "original_get_html", + "uid": "uid", + "edxnotes_visibility": "true", + "params": { + "usageId": u"test_usage_id", + "courseId": unicode(self.course.id).encode("utf-8"), + "token": "token", + "tokenUrl": "/tokenUrl", + "endpoint": "/endpoint", + "debug": settings.DEBUG, + }, + } + self.assertEqual( + self.problem.get_html(), + render_to_string("edxnotes_wrapper.html", expected_context), + ) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + def test_edxnotes_disabled_if_edxnotes_flag_is_false(self): + """ + Tests that get_html is wrapped when feature flag is on, but edxnotes are + disabled for the course. + """ + self.assertEqual("original_get_html", self.problem.get_html()) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) + def test_edxnotes_disabled(self): + """ + Tests that get_html is not wrapped when feature flag is off. + """ + self.assertEqual("original_get_html", self.problem.get_html()) + + def test_edxnotes_studio(self): + """ + Tests that get_html is not wrapped when problem is rendered in Studio. + """ + self.problem.system.is_author_mode = True + self.assertEqual("original_get_html", self.problem.get_html()) + + def test_edxnotes_harvard_notes_enabled(self): + """ + Tests that get_html is not wrapped when Harvard Annotation Tool is enabled. + """ + self.course.advanced_modules = ["videoannotation", "imageannotation", "textannotation"] + enable_edxnotes_for_the_course(self.course, self.user.id) + self.assertEqual("original_get_html", self.problem.get_html()) + + +@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") +class EdxNotesHelpersTest(ModuleStoreTestCase): + """ + Tests for EdxNotes helpers. + """ + def setUp(self): + """ + Setup a dummy course content. + """ + super(EdxNotesHelpersTest, self).setUp() + modulestore().request_cache.data = {} + ClientFactory(name="edx-notes") + self.course = CourseFactory.create() + self.chapter = ItemFactory.create(category="chapter", parent_location=self.course.location) + self.chapter_2 = ItemFactory.create(category="chapter", parent_location=self.course.location) + self.sequential = ItemFactory.create(category="sequential", parent_location=self.chapter.location) + self.vertical = ItemFactory.create(category="vertical", parent_location=self.sequential.location) + self.html_module_1 = ItemFactory.create(category="html", parent_location=self.vertical.location) + self.html_module_2 = ItemFactory.create(category="html", parent_location=self.vertical.location) + self.vertical_with_container = ItemFactory.create(category='vertical', parent_location=self.sequential.location) + self.child_container = ItemFactory.create( + category='split_test', parent_location=self.vertical_with_container.location) + self.child_vertical = ItemFactory.create(category='vertical', parent_location=self.child_container.location) + self.child_html_module = ItemFactory.create(category="html", parent_location=self.child_vertical.location) + + # Read again so that children lists are accurate + self.course = self.store.get_item(self.course.location) + self.chapter = self.store.get_item(self.chapter.location) + self.chapter_2 = self.store.get_item(self.chapter_2.location) + self.sequential = self.store.get_item(self.sequential.location) + self.vertical = self.store.get_item(self.vertical.location) + + self.vertical_with_container = self.store.get_item(self.vertical_with_container.location) + self.child_container = self.store.get_item(self.child_container.location) + self.child_vertical = self.store.get_item(self.child_vertical.location) + self.child_html_module = self.store.get_item(self.child_html_module.location) + + self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx") + self.client.login(username=self.user.username, password="edx") + + def _get_unit_url(self, course, chapter, section, position=1): + """ + Returns `jump_to_id` url for the `vertical`. + """ + return reverse('courseware_position', kwargs={ + 'course_id': course.id, + 'chapter': chapter.url_name, + 'section': section.url_name, + 'position': position, + }) + + def test_edxnotes_not_enabled(self): + """ + Tests that edxnotes are disabled when the course tab configuration does NOT + contain a tab with type "edxnotes." + """ + self.course.tabs = [] + self.assertFalse(helpers.is_feature_enabled(self.course)) + + def test_edxnotes_harvard_notes_enabled(self): + """ + Tests that edxnotes are disabled when Harvard Annotation Tool is enabled. + """ + self.course.advanced_modules = ["foo", "imageannotation", "boo"] + self.assertFalse(helpers.is_feature_enabled(self.course)) + + self.course.advanced_modules = ["foo", "boo", "videoannotation"] + self.assertFalse(helpers.is_feature_enabled(self.course)) + + self.course.advanced_modules = ["textannotation", "foo", "boo"] + self.assertFalse(helpers.is_feature_enabled(self.course)) + + self.course.advanced_modules = ["textannotation", "videoannotation", "imageannotation"] + self.assertFalse(helpers.is_feature_enabled(self.course)) + + def test_edxnotes_enabled(self): + """ + Tests that edxnotes are enabled when the course tab configuration contains + a tab with type "edxnotes." + """ + self.course.tabs = [{"type": "foo"}, + {"name": "Notes", "type": "edxnotes"}, + {"type": "bar"}] + self.assertTrue(helpers.is_feature_enabled(self.course)) + + def test_get_endpoint(self): + """ + Tests that storage_url method returns appropriate values. + """ + # url ends with "/" + with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com/"}): + self.assertEqual("http://example.com/", helpers.get_endpoint()) + + # url doesn't have "/" at the end + with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}): + self.assertEqual("http://example.com/", helpers.get_endpoint()) + + # url with path that starts with "/" + with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}): + self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("/some_path")) + + # url with path without "/" + with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}): + self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("some_path/")) + + # url is not configured + with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": None}): + self.assertRaises(ImproperlyConfigured, helpers.get_endpoint) + + @patch("edxnotes.helpers.requests.get") + def test_get_notes_correct_data(self, mock_get): + """ + Tests the result if correct data is received. + """ + mock_get.return_value.content = json.dumps([ + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), + }, + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_2.location), + u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), + } + ]) + + self.assertItemsEqual( + [ + { + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_2.location), + u"updated": "Nov 19, 2014 at 08:06 UTC", + }, + { + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [ + unicode(self.vertical.location), + unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_1.location), + u"updated": "Nov 19, 2014 at 08:05 UTC", + }, + ], + json.loads(helpers.get_notes(self.user, self.course)) + ) + + @patch("edxnotes.helpers.requests.get") + def test_get_notes_json_error(self, mock_get): + """ + Tests the result if incorrect json is received. + """ + mock_get.return_value.content = "Error" + self.assertIsNone(helpers.get_notes(self.user, self.course)) + + @patch("edxnotes.helpers.requests.get") + def test_get_notes_empty_collection(self, mock_get): + """ + Tests the result if an empty collection is received. + """ + mock_get.return_value.content = json.dumps([]) + self.assertIsNone(helpers.get_notes(self.user, self.course)) + + @patch("edxnotes.helpers.requests.get") + def test_search_correct_data(self, mock_get): + """ + Tests the result if correct data is received. + """ + mock_get.return_value.content = json.dumps({ + "total": 2, + "rows": [ + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), + }, + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_2.location), + u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), + } + ] + }) + + self.assertItemsEqual( + { + "total": 2, + "rows": [ + { + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [ + unicode(self.vertical.location), + unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_2.location), + u"updated": "Nov 19, 2014 at 08:06 UTC", + }, + { + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [ + unicode(self.vertical.location), + unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_1.location), + u"updated": "Nov 19, 2014 at 08:05 UTC", + }, + ] + }, + json.loads(helpers.search(self.user, self.course, "test")) + ) + + @patch("edxnotes.helpers.requests.get") + def test_search_json_error(self, mock_get): + """ + Tests the result if incorrect json is received. + """ + mock_get.return_value.content = "Error" + self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test") + + @patch("edxnotes.helpers.requests.get") + def test_search_wrong_data_format(self, mock_get): + """ + Tests the result if incorrect data structure is received. + """ + mock_get.return_value.content = json.dumps({"1": 2}) + self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test") + + @patch("edxnotes.helpers.requests.get") + def test_search_empty_collection(self, mock_get): + """ + Tests no results. + """ + mock_get.return_value.content = json.dumps({ + "total": 0, + "rows": [] + }) + self.assertItemsEqual( + { + "total": 0, + "rows": [] + }, + json.loads(helpers.search(self.user, self.course, "test")) + ) + + def test_preprocess_collection_escaping(self): + """ + Tests the result if appropriate module is not found. + """ + initial_collection = [{ + u"quote": u"test ", + u"text": u"text \"<>&'", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat() + }] + + self.assertItemsEqual( + [{ + u"quote": u"test <script>alert('test')</script>", + u"text": u'text "<>&\'', + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000), + }], + helpers.preprocess_collection(self.user, self.course, initial_collection) + ) + + def test_preprocess_collection_no_item(self): + """ + Tests the result if appropriate module is not found. + """ + initial_collection = [ + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat() + }, + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.course.id.make_usage_key("html", "test_item")), + u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat() + }, + ] + + self.assertItemsEqual( + [{ + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000), + }], + helpers.preprocess_collection(self.user, self.course, initial_collection) + ) + + def test_preprocess_collection_has_access(self): + """ + Tests the result if the user does not have access to some of the modules. + """ + initial_collection = [ + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), + }, + { + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_2.location), + u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), + }, + ] + self.html_module_2.visible_to_staff_only = True + self.store.update_item(self.html_module_2, self.user.id) + self.assertItemsEqual( + [{ + u"quote": u"quote text", + u"text": u"text", + u"chapter": { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)] + }, + u"section": { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)] + }, + u"unit": { + u"url": self._get_unit_url(self.course, self.chapter, self.sequential), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000), + }], + helpers.preprocess_collection(self.user, self.course, initial_collection) + ) + + @patch("edxnotes.helpers.has_access") + @patch("edxnotes.helpers.modulestore") + def test_preprocess_collection_no_unit(self, mock_modulestore, mock_has_access): + """ + Tests the result if the unit does not exist. + """ + store = MagicMock() + store.get_parent_location.return_value = None + mock_modulestore.return_value = store + mock_has_access.return_value = True + initial_collection = [{ + u"quote": u"quote text", + u"text": u"text", + u"usage_id": unicode(self.html_module_1.location), + u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), + }] + + self.assertItemsEqual( + [], helpers.preprocess_collection(self.user, self.course, initial_collection) + ) + + def test_get_parent_xblock(self): + """ + Tests `get_parent_xblock` method to return parent xblock or None + """ + for _ in range(2): + # repeat the test twice to make sure caching does not interfere + self.assertEqual(helpers.get_parent_xblock(self.html_module_1).location, self.vertical.location) + self.assertEqual(helpers.get_parent_xblock(self.sequential).location, self.chapter.location) + self.assertEqual(helpers.get_parent_xblock(self.chapter).location, self.course.location) + self.assertIsNone(helpers.get_parent_xblock(self.course)) + + def test_get_parent_unit(self): + """ + Tests `get_parent_unit` method for the successful result. + """ + parent = helpers.get_parent_unit(self.html_module_1) + self.assertEqual(parent.location, self.vertical.location) + + parent = helpers.get_parent_unit(self.child_html_module) + self.assertEqual(parent.location, self.vertical_with_container.location) + + self.assertIsNone(helpers.get_parent_unit(None)) + self.assertIsNone(helpers.get_parent_unit(self.course)) + self.assertIsNone(helpers.get_parent_unit(self.chapter)) + self.assertIsNone(helpers.get_parent_unit(self.sequential)) + + def test_get_module_context_sequential(self): + """ + Tests `get_module_context` method for the sequential. + """ + self.assertDictEqual( + { + u"display_name": self.sequential.display_name_with_default, + u"location": unicode(self.sequential.location), + u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)], + }, + helpers.get_module_context(self.course, self.sequential) + ) + + def test_get_module_context_html_component(self): + """ + Tests `get_module_context` method for the components. + """ + self.assertDictEqual( + { + u"display_name": self.html_module_1.display_name_with_default, + u"location": unicode(self.html_module_1.location), + }, + helpers.get_module_context(self.course, self.html_module_1) + ) + + def test_get_module_context_chapter(self): + """ + Tests `get_module_context` method for the chapters. + """ + self.assertDictEqual( + { + u"display_name": self.chapter.display_name_with_default, + u"index": 0, + u"location": unicode(self.chapter.location), + u"children": [unicode(self.sequential.location)], + }, + helpers.get_module_context(self.course, self.chapter) + ) + self.assertDictEqual( + { + u"display_name": self.chapter_2.display_name_with_default, + u"index": 1, + u"location": unicode(self.chapter_2.location), + u"children": [], + }, + helpers.get_module_context(self.course, self.chapter_2) + ) + + @patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}) + @patch("edxnotes.helpers.anonymous_id_for_user") + @patch("edxnotes.helpers.get_id_token") + @patch("edxnotes.helpers.requests.get") + def test_send_request_with_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): + """ + Tests that requests are send with correct information. + """ + mock_get_id_token.return_value = "test_token" + mock_anonymous_id_for_user.return_value = "anonymous_id" + helpers.send_request( + self.user, self.course.id, path="test", query_string="text" + ) + mock_get.assert_called_with( + "http://example.com/test/", + headers={ + "x-annotator-auth-token": "test_token" + }, + params={ + "user": "anonymous_id", + "course_id": unicode(self.course.id), + "text": "text", + "highlight": True, + "highlight_tag": "span", + "highlight_class": "note-highlight", + } + ) + + @patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}) + @patch("edxnotes.helpers.anonymous_id_for_user") + @patch("edxnotes.helpers.get_id_token") + @patch("edxnotes.helpers.requests.get") + def test_send_request_without_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): + """ + Tests that requests are send with correct information. + """ + mock_get_id_token.return_value = "test_token" + mock_anonymous_id_for_user.return_value = "anonymous_id" + helpers.send_request( + self.user, self.course.id, path="test" + ) + mock_get.assert_called_with( + "http://example.com/test/", + headers={ + "x-annotator-auth-token": "test_token" + }, + params={ + "user": "anonymous_id", + "course_id": unicode(self.course.id), + } + ) + + def test_get_course_position_no_chapter(self): + """ + Returns `None` if no chapter found. + """ + mock_course_module = MagicMock() + mock_course_module.position = 3 + mock_course_module.get_display_items.return_value = [] + self.assertIsNone(helpers.get_course_position(mock_course_module)) + + def test_get_course_position_to_chapter(self): + """ + Returns a position that leads to COURSE/CHAPTER if this isn't the users's + first time. + """ + mock_course_module = MagicMock(id=self.course.id, position=3) + + mock_chapter = MagicMock() + mock_chapter.url_name = 'chapter_url_name' + mock_chapter.display_name_with_default = 'Test Chapter Display Name' + + mock_course_module.get_display_items.return_value = [mock_chapter] + + self.assertEqual(helpers.get_course_position(mock_course_module), { + 'display_name': 'Test Chapter Display Name', + 'url': '/courses/{}/courseware/chapter_url_name/'.format(self.course.id), + }) + + def test_get_course_position_no_section(self): + """ + Returns `None` if no section found. + """ + mock_course_module = MagicMock(id=self.course.id, position=None) + mock_course_module.get_display_items.return_value = [MagicMock()] + self.assertIsNone(helpers.get_course_position(mock_course_module)) + + def test_get_course_position_to_section(self): + """ + Returns a position that leads to COURSE/CHAPTER/SECTION if this is the + user's first time. + """ + mock_course_module = MagicMock(id=self.course.id, position=None) + + mock_chapter = MagicMock() + mock_chapter.url_name = 'chapter_url_name' + mock_course_module.get_display_items.return_value = [mock_chapter] + + mock_section = MagicMock() + mock_section.url_name = 'section_url_name' + mock_section.display_name_with_default = 'Test Section Display Name' + + mock_chapter.get_display_items.return_value = [mock_section] + mock_section.get_display_items.return_value = [MagicMock()] + + self.assertEqual(helpers.get_course_position(mock_course_module), { + 'display_name': 'Test Section Display Name', + 'url': '/courses/{}/courseware/chapter_url_name/section_url_name/'.format(self.course.id), + }) + + def test_get_index(self): + """ + Tests `get_index` method returns unit url. + """ + children = self.sequential.children + self.assertEqual(0, helpers.get_index(unicode(self.vertical.location), children)) + self.assertEqual(1, helpers.get_index(unicode(self.vertical_with_container.location), children)) + + +@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") +class EdxNotesViewsTest(TestCase): + """ + Tests for EdxNotes views. + """ + def setUp(self): + ClientFactory(name="edx-notes") + super(EdxNotesViewsTest, self).setUp() + self.course = CourseFactory.create(edxnotes=True) + self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + self.client.login(username=self.user.username, password="edx") + self.notes_page_url = reverse("edxnotes", args=[unicode(self.course.id)]) + self.search_url = reverse("search_notes", args=[unicode(self.course.id)]) + self.get_token_url = reverse("get_token", args=[unicode(self.course.id)]) + self.visibility_url = reverse("edxnotes_visibility", args=[unicode(self.course.id)]) + + def _get_course_module(self): + """ + Returns the course module. + """ + field_data_cache = FieldDataCache([self.course], self.course.id, self.user) + return get_module_for_descriptor(self.user, MagicMock(), self.course, field_data_cache, self.course.id) + + # pylint: disable=unused-argument + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.get_notes", return_value=[]) + def test_edxnotes_view_is_enabled(self, mock_get_notes): + """ + Tests that appropriate view is received if EdxNotes feature is enabled. + """ + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.notes_page_url) + self.assertContains(response, 'Highlights and notes you\'ve made in course content') + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) + def test_edxnotes_view_is_disabled(self): + """ + Tests that 404 status code is received if EdxNotes feature is disabled. + """ + response = self.client.get(self.notes_page_url) + self.assertEqual(response.status_code, 404) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.get_notes") + def test_edxnotes_view_404_service_unavailable(self, mock_get_notes): + """ + Tests that 404 status code is received if EdxNotes service is unavailable. + """ + mock_get_notes.side_effect = EdxNotesServiceUnavailable + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.notes_page_url) + self.assertEqual(response.status_code, 404) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.search") + def test_search_notes_successfully_respond(self, mock_search): + """ + Tests that `search_notes` successfully respond if EdxNotes feature is enabled. + """ + mock_search.return_value = json.dumps({ + "total": 0, + "rows": [], + }) + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.search_url, {"text": "test"}) + self.assertEqual(json.loads(response.content), { + "total": 0, + "rows": [], + }) + self.assertEqual(response.status_code, 200) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) + @patch("edxnotes.views.search") + def test_search_notes_is_disabled(self, mock_search): + """ + Tests that 404 status code is received if EdxNotes feature is disabled. + """ + mock_search.return_value = json.dumps({ + "total": 0, + "rows": [], + }) + response = self.client.get(self.search_url, {"text": "test"}) + self.assertEqual(response.status_code, 404) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.search") + def test_search_404_service_unavailable(self, mock_search): + """ + Tests that 404 status code is received if EdxNotes service is unavailable. + """ + mock_search.side_effect = EdxNotesServiceUnavailable + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.search_url, {"text": "test"}) + self.assertEqual(response.status_code, 500) + self.assertIn("error", response.content) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.search") + def test_search_notes_without_required_parameters(self, mock_search): + """ + Tests that 400 status code is received if the required parameters were not sent. + """ + mock_search.return_value = json.dumps({ + "total": 0, + "rows": [], + }) + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.search_url) + self.assertEqual(response.status_code, 400) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + @patch("edxnotes.views.search") + def test_search_notes_exception(self, mock_search): + """ + Tests that 500 status code is received if invalid data was received from + EdXNotes service. + """ + mock_search.side_effect = EdxNotesParseError + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.get(self.search_url, {"text": "test"}) + self.assertEqual(response.status_code, 500) + self.assertIn("error", response.content) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + def test_get_id_token(self): + """ + Test generation of ID Token. + """ + response = self.client.get(self.get_token_url) + self.assertEqual(response.status_code, 200) + client = Client.objects.get(name='edx-notes') + jwt.decode(response.content, client.client_secret) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + def test_get_id_token_anonymous(self): + """ + Test that generation of ID Token does not work for anonymous user. + """ + self.client.logout() + response = self.client.get(self.get_token_url) + self.assertEqual(response.status_code, 302) + + def test_edxnotes_visibility(self): + """ + Can update edxnotes_visibility value successfully. + """ + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.post( + self.visibility_url, + data=json.dumps({"visibility": False}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + course_module = self._get_course_module() + self.assertFalse(course_module.edxnotes_visibility) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) + def test_edxnotes_visibility_if_feature_is_disabled(self): + """ + Tests that 404 response is received if EdxNotes feature is disabled. + """ + response = self.client.post(self.visibility_url) + self.assertEqual(response.status_code, 404) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + def test_edxnotes_visibility_invalid_json(self): + """ + Tests that 400 response is received if invalid JSON is sent. + """ + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.post( + self.visibility_url, + data="string", + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) + def test_edxnotes_visibility_key_error(self): + """ + Tests that 400 response is received if invalid data structure is sent. + """ + enable_edxnotes_for_the_course(self.course, self.user.id) + response = self.client.post( + self.visibility_url, + data=json.dumps({'test_key': 1}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) diff --git a/lms/djangoapps/edxnotes/urls.py b/lms/djangoapps/edxnotes/urls.py new file mode 100644 index 0000000000..17dc36b5e5 --- /dev/null +++ b/lms/djangoapps/edxnotes/urls.py @@ -0,0 +1,13 @@ +""" +URLs for EdxNotes. +""" +from django.conf.urls import patterns, url + +# Additionally, we include login URLs for the browseable API. +urlpatterns = patterns( + "edxnotes.views", + url(r"^/$", "edxnotes", name="edxnotes"), + url(r"^/search/$", "search_notes", name="search_notes"), + url(r"^/token/$", "get_token", name="get_token"), + url(r"^/visibility/$", "edxnotes_visibility", name="edxnotes_visibility"), +) diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py new file mode 100644 index 0000000000..badc6b4375 --- /dev/null +++ b/lms/djangoapps/edxnotes/views.py @@ -0,0 +1,120 @@ +""" +Views related to EdxNotes. +""" +import json +import logging +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest, Http404 +from django.conf import settings +from django.core.urlresolvers import reverse +from edxmako.shortcuts import render_to_response +from opaque_keys.edx.keys import CourseKey +from courseware.courses import get_course_with_access +from courseware.model_data import FieldDataCache +from courseware.module_render import get_module_for_descriptor +from util.json_request import JsonResponse, JsonResponseBadRequest +from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable +from edxnotes.helpers import ( + get_notes, + get_id_token, + is_feature_enabled, + search, + get_course_position, +) + +log = logging.getLogger(__name__) + + +@login_required +def edxnotes(request, course_id): + """ + Displays the EdxNotes page. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, "load", course_key) + + if not is_feature_enabled(course): + raise Http404 + + try: + notes = get_notes(request.user, course) + except EdxNotesServiceUnavailable: + raise Http404 + + context = { + "course": course, + "search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}), + "notes": notes, + "debug": json.dumps(settings.DEBUG), + 'position': None, + } + + if not notes: + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course.id, request.user, course, depth=2 + ) + course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key) + position = get_course_position(course_module) + if position: + context.update({ + 'position': position, + }) + + return render_to_response("edxnotes/edxnotes.html", context) + + +@login_required +def search_notes(request, course_id): + """ + Handles search requests. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, "load", course_key) + + if not is_feature_enabled(course): + raise Http404 + + if "text" not in request.GET: + return HttpResponseBadRequest() + + query_string = request.GET["text"] + try: + search_results = search(request.user, course, query_string) + except (EdxNotesParseError, EdxNotesServiceUnavailable) as err: + return JsonResponseBadRequest({"error": err.message}, status=500) + + return HttpResponse(search_results) + + +# pylint: disable=unused-argument +@login_required +def get_token(request, course_id): + """ + Get JWT ID-Token, in case you need new one. + """ + return HttpResponse(get_id_token(request.user), content_type='text/plain') + + +@login_required +def edxnotes_visibility(request, course_id): + """ + Handle ajax call from "Show notes" checkbox. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, "load", course_key) + field_data_cache = FieldDataCache([course], course_key, request.user) + course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key) + + if not is_feature_enabled(course): + raise Http404 + + try: + visibility = json.loads(request.body)["visibility"] + course_module.edxnotes_visibility = visibility + course_module.save() + return JsonResponse(status=200) + except (ValueError, KeyError): + log.warning( + "Could not decode request body as JSON and find a boolean visibility field: '%s'", request.body + ) + return JsonResponseBadRequest() diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index af3b992808..a423020715 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -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() diff --git a/lms/envs/common.py b/lms/envs/common.py index 8623017d42..54ab359e55 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 diff --git a/lms/envs/test.py b/lms/envs/test.py index ce0ceeb951..b75ae35ae3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -425,3 +425,6 @@ MONGODB_LOG = { 'password': '', 'db': 'xlog', } + +# Enable EdxNotes for tests. +FEATURES['ENABLE_EDXNOTES'] = True diff --git a/lms/static/coffee/src/calculator.coffee b/lms/static/coffee/src/calculator.coffee index 230ff5e922..60fe3936fd 100644 --- a/lms/static/coffee/src/calculator.coffee +++ b/lms/static/coffee/src/calculator.coffee @@ -72,7 +72,7 @@ class @Calculator .attr 'title': text 'aria-expanded': isExpanded - .text text + .find('.utility-control-label').text text $calc.toggleClass 'closed' diff --git a/lms/static/coffee/src/courseware.coffee b/lms/static/coffee/src/courseware.coffee index 06a444c8cd..50fcb3ecec 100644 --- a/lms/static/coffee/src/courseware.coffee +++ b/lms/static/coffee/src/courseware.coffee @@ -2,7 +2,6 @@ class @Courseware @prefix: '' constructor: -> - Courseware.prefix = $("meta[name='path_prefix']").attr('content') new Navigation Logger.bind() @render() diff --git a/lms/static/coffee/src/main.coffee b/lms/static/coffee/src/main.coffee index 770b17ea66..561225761d 100644 --- a/lms/static/coffee/src/main.coffee +++ b/lms/static/coffee/src/main.coffee @@ -1,4 +1,4 @@ -AjaxPrefix.addAjaxPrefix(jQuery, -> Courseware.prefix) +AjaxPrefix.addAjaxPrefix(jQuery, -> $("meta[name='path_prefix']").attr('content')) $ -> $.ajaxSetup diff --git a/lms/static/js/edxnotes/collections/notes.js b/lms/static/js/edxnotes/collections/notes.js new file mode 100644 index 0000000000..dfc7d06665 --- /dev/null +++ b/lms/static/js/edxnotes/collections/notes.js @@ -0,0 +1,46 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'backbone', 'js/edxnotes/models/note' +], function (Backbone, NoteModel) { + var NotesCollection = Backbone.Collection.extend({ + model: NoteModel, + + /** + * Returns course structure from the list of notes. + * @return {Object} + */ + getCourseStructure: (function () { + var courseStructure = null; + return function () { + var chapters = {}, + sections = {}, + units = {}; + + if (!courseStructure) { + this.each(function (note) { + var chapter = note.get('chapter'), + section = note.get('section'), + unit = note.get('unit'); + + chapters[chapter.location] = chapter; + sections[section.location] = section; + units[unit.location] = units[unit.location] || []; + units[unit.location].push(note); + }); + + courseStructure = { + chapters: _.sortBy(_.toArray(chapters), function (c) {return c.index;}), + sections: sections, + units: units + }; + } + + return courseStructure; + }; + }()) + }); + + return NotesCollection; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/collections/tabs.js b/lms/static/js/edxnotes/collections/tabs.js new file mode 100644 index 0000000000..bb709c16c1 --- /dev/null +++ b/lms/static/js/edxnotes/collections/tabs.js @@ -0,0 +1,12 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'backbone', 'js/edxnotes/models/tab' +], function (Backbone, TabModel) { + var TabsCollection = Backbone.Collection.extend({ + model: TabModel + }); + + return TabsCollection; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/models/note.js b/lms/static/js/edxnotes/models/note.js new file mode 100644 index 0000000000..001f20c43d --- /dev/null +++ b/lms/static/js/edxnotes/models/note.js @@ -0,0 +1,59 @@ +;(function (define) { +'use strict'; +define(['backbone', 'underscore.string'], function (Backbone) { + var NoteModel = Backbone.Model.extend({ + defaults: { + 'id': null, + 'created': '', + 'updated': '', + 'user': '', + 'usage_id': '', + 'course_id': '', + 'text': '', + 'quote': '', + 'ranges': [], + 'unit': { + 'display_name': '', + 'url': '', + 'location': '' + }, + 'section': { + 'display_name': '', + 'location': '', + 'children': [] + }, + 'chapter': { + 'display_name': '', + 'location': '', + 'index': 0, + 'children': [] + }, + // Flag indicating current state of the note: expanded or collapsed. + 'is_expanded': false, + // Flag indicating whether `More` link should be shown. + 'show_link': false + }, + + textSize: 300, + + initialize: function () { + if (this.get('quote').length > this.textSize) { + this.set('show_link', true); + } + }, + + getNoteText: function () { + var message = this.get('quote'); + + if (!this.get('is_expanded') && this.get('show_link')) { + message = _.str.prune(message, this.textSize); + } + + return message; + } + + }); + + return NoteModel; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/models/tab.js b/lms/static/js/edxnotes/models/tab.js new file mode 100644 index 0000000000..07882cd702 --- /dev/null +++ b/lms/static/js/edxnotes/models/tab.js @@ -0,0 +1,34 @@ +;(function (define, undefined) { +'use strict'; +define(['backbone'], function (Backbone) { + var TabModel = Backbone.Model.extend({ + defaults: { + 'identifier': '', + 'name': '', + 'icon': '', + 'is_active': false, + 'is_closable': false + }, + + activate: function () { + this.collection.each(_.bind(function(model) { + // Inactivate all other models. + if (model !== this) { + model.inactivate(); + } + }, this)); + this.set('is_active', true); + }, + + inactivate: function () { + this.set('is_active', false); + }, + + isActive: function () { + return this.get('is_active'); + } + }); + + return TabModel; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/plugins/events.js b/lms/static/js/edxnotes/plugins/events.js new file mode 100644 index 0000000000..e8122de0e5 --- /dev/null +++ b/lms/static/js/edxnotes/plugins/events.js @@ -0,0 +1,155 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'underscore', 'annotator', 'underscore.string' +], function (_, Annotator) { + /** + * Modifies Annotator.Plugin.Store.annotationCreated to make it trigger a new + * event `annotationFullyCreated` when annotation is fully created and has + * an id. + */ + Annotator.Plugin.Store.prototype.annotationCreated = _.compose( + function (jqXhr) { + return jqXhr.done(_.bind(function (annotation) { + if (annotation && annotation.id){ + this.publish('annotationFullyCreated', annotation); + } + }, this)); + }, + Annotator.Plugin.Store.prototype.annotationCreated + ); + + /** + * Adds the Events Plugin which emits events to capture user intent. + * Emits the following events: + * - 'edx.course.student_notes.viewed' + * [(user, note ID, datetime), (user, note ID, datetime)] - a list of notes. + * - 'edx.course.student_notes.added' + * (user, note ID, note text, highlighted content, ID of the component annotated, datetime) + * - 'edx.course.student_notes.edited' + * (user, note ID, old note text, new note text, highlighted content, ID of the component annotated, datetime) + * - 'edx.course.student_notes.deleted' + * (user, note ID, note text, highlighted content, ID of the component annotated, datetime) + **/ + Annotator.Plugin.Events = function () { + // Call the Annotator.Plugin constructor this sets up the element and + // options properties. + Annotator.Plugin.apply(this, arguments); + }; + + _.extend(Annotator.Plugin.Events.prototype, new Annotator.Plugin(), { + pluginInit: function () { + _.bindAll(this, + 'annotationViewerShown', 'annotationFullyCreated', 'annotationEditorShown', + 'annotationEditorHidden', 'annotationUpdated', 'annotationDeleted' + ); + + this.annotator + .subscribe('annotationViewerShown', this.annotationViewerShown) + .subscribe('annotationFullyCreated', this.annotationFullyCreated) + .subscribe('annotationEditorShown', this.annotationEditorShown) + .subscribe('annotationEditorHidden', this.annotationEditorHidden) + .subscribe('annotationUpdated', this.annotationUpdated) + .subscribe('annotationDeleted', this.annotationDeleted); + }, + + destroy: function () { + this.annotator + .unsubscribe('annotationViewerShown', this.annotationViewerShown) + .unsubscribe('annotationFullyCreated', this.annotationFullyCreated) + .unsubscribe('annotationEditorShown', this.annotationEditorShown) + .unsubscribe('annotationEditorHidden', this.annotationEditorHidden) + .unsubscribe('annotationUpdated', this.annotationUpdated) + .unsubscribe('annotationDeleted', this.annotationDeleted); + }, + + annotationViewerShown: function (viewer, annotations) { + // Emits an event only when the annotation already exists on the + // server. Otherwise, `annotation.id` is `undefined`. + var data; + annotations = _.reject(annotations, this.isNew); + data = { + 'notes': _.map(annotations, function (annotation) { + return {'note_id': annotation.id}; + }) + }; + if (data.notes.length) { + this.log('edx.course.student_notes.viewed', data); + } + }, + + annotationFullyCreated: function (annotation) { + var data = this.getDefaultData(annotation); + this.log('edx.course.student_notes.added', data); + }, + + annotationEditorShown: function (editor, annotation) { + this.oldNoteText = annotation.text || ''; + }, + + annotationEditorHidden: function () { + this.oldNoteText = null; + }, + + annotationUpdated: function (annotation) { + var data; + if (!this.isNew(annotation)) { + data = _.extend( + this.getDefaultData(annotation), + this.getText('old_note_text', this.oldNoteText) + ); + this.log('edx.course.student_notes.edited', data); + } + }, + + annotationDeleted: function (annotation) { + var data; + // Emits an event only when the annotation already exists on the + // server. + if (!this.isNew(annotation)) { + data = this.getDefaultData(annotation); + this.log('edx.course.student_notes.deleted', data); + } + }, + + getDefaultData: function (annotation) { + return _.extend( + { + 'note_id': annotation.id, + 'component_usage_id': annotation.usage_id + }, + this.getText('note_text', annotation.text), + this.getText('highlighted_content', annotation.quote) + ); + }, + + getText: function (fieldName, text) { + var info = {}, + truncated = false, + limit = this.options.stringLimit; + + if (_.isNumber(limit) && _.isString(text) && text.length > limit) { + text = String(text).slice(0, limit); + truncated = true; + } + + info[fieldName] = text; + info[fieldName + '_truncated'] = truncated; + + return info; + }, + + /** + * If the model does not yet have an id, it is considered to be new. + * @return {Boolean} + */ + isNew: function (annotation) { + return !_.has(annotation, 'id'); + }, + + log: function (eventName, data) { + this.annotator.logger.emit(eventName, data); + } + }); +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/plugins/scroller.js b/lms/static/js/edxnotes/plugins/scroller.js new file mode 100644 index 0000000000..0c717aff45 --- /dev/null +++ b/lms/static/js/edxnotes/plugins/scroller.js @@ -0,0 +1,65 @@ +;(function (define, undefined) { +'use strict'; +define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { + /** + * Adds the Scroller Plugin which scrolls to a note with a certain id and + * opens it. + **/ + Annotator.Plugin.Scroller = function () { + // Call the Annotator.Plugin constructor this sets up the element and + // options properties. + Annotator.Plugin.apply(this, arguments); + }; + + $.extend(Annotator.Plugin.Scroller.prototype, new Annotator.Plugin(), { + getIdFromLocationHash: function() { + return window.location.hash.substr(1); + }, + + pluginInit: function () { + _.bindAll(this, 'onNotesLoaded'); + // If the page URL contains a hash, we could be coming from a click + // on an anchor in the notes page. In that case, the hash is the id + // of the note that has to be scrolled to and opened. + if (this.getIdFromLocationHash()) { + this.annotator.subscribe('annotationsLoaded', this.onNotesLoaded); + } + }, + + destroy: function () { + this.annotator.unsubscribe('annotationsLoaded', this.onNotesLoaded); + }, + + onNotesLoaded: function (notes) { + var hash = this.getIdFromLocationHash(); + this.annotator.logger.log('Scroller', { + 'notes:': notes, + 'hash': hash + }); + _.each(notes, function (note) { + var highlight, offset; + if (note.id === hash && note.highlights.length) { + // Clear the page URL hash, it won't be needed once we've + // scrolled and opened the relevant note. And it would + // unnecessarily repeat the steps below if we come from + // another sequential. + window.location.hash = ''; + highlight = $(note.highlights[0]); + offset = highlight.position(); + // Open the note + this.annotator.showFrozenViewer([note], { + top: offset.top + 0.5 * highlight.height(), + left: offset.left + 0.5 * highlight.width() + }); + // Scroll to highlight + this.scrollIntoView(highlight); + } + }, this); + }, + + scrollIntoView: function (highlight) { + highlight.focus(); + } + }); +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/utils/logger.js b/lms/static/js/edxnotes/utils/logger.js new file mode 100644 index 0000000000..92ac9a4175 --- /dev/null +++ b/lms/static/js/edxnotes/utils/logger.js @@ -0,0 +1,150 @@ +;(function (define) { +'use strict'; +define(['underscore', 'logger'], function (_, Logger) { + var loggers = [], + NotesLogger, now, destroyLogger; + + now = function () { + if (performance && performance.now) { + return performance.now(); + } else if (Date.now) { + return Date.now(); + } else { + return (new Date()).getTime(); + } + }; + + /** + * Removes a reference on the logger from `loggers`. + * @param {Object} logger An instance of Logger. + */ + destroyLogger = function (logger) { + var index = loggers.length, + removedLogger; + + while(index--) { + if (loggers[index].id === logger.id) { + removedLogger = loggers.splice(index, 1)[0]; + removedLogger.historyStorage = []; + removedLogger.timeStorage = {}; + break; + } + } + }; + + /** + * NotesLogger constructor. + * @constructor + * @param {String} id Id of the logger. + * @param {Boolean|Number} mode Outputs messages to the Web Console if true. + */ + NotesLogger = function (id, mode) { + this.id = id; + this.historyStorage = []; + this.timeStorage = {}; + // 0 - silent; + // 1 - show logs; + this.logLevel = mode; + }; + + /** + * Outputs a message with appropriate type to the Web Console and + * store it in the history. + * @param {String} logType The type of the log message. + * @param {Arguments} args Information that will be stored. + */ + NotesLogger.prototype._log = function (logType, args) { + if (!this.logLevel) { + return false; + } + this.updateHistory.apply(this, arguments); + // Adds ID at the first place + Array.prototype.unshift.call(args, this.id); + if (console && console[logType]) { + if (console[logType].apply){ + console[logType].apply(console, args); + } else { // Do this for IE + console[logType](args.join(' ')); + } + } + }; + + /** + * Outputs a message to the Web Console and store it in the history. + */ + NotesLogger.prototype.log = function () { + this._log('log', arguments); + }; + + /** + * Outputs an error message to the Web Console and store it in the history. + */ + NotesLogger.prototype.error = function () { + this._log('error', arguments); + }; + + /** + * Adds information to the history. + */ + NotesLogger.prototype.updateHistory = function () { + this.historyStorage.push(arguments); + }; + + /** + * Returns the history for the logger. + * @return {Array} + */ + NotesLogger.prototype.getHistory = function () { + return this.historyStorage; + }; + + /** + * Starts a timer you can use to track how long an operation takes. + * @param {String} label Timer name. + */ + NotesLogger.prototype.time = function (label) { + this.timeStorage[label] = now(); + }; + + /** + * Stops a timer that was previously started by calling NotesLogger.prototype.time(). + * @param {String} label Timer name. + */ + NotesLogger.prototype.timeEnd = function (label) { + if (!this.timeStorage[label]) { + return null; + } + + this._log('log', [label, now() - this.timeStorage[label], 'ms']); + delete this.timeStorage[label]; + }; + + NotesLogger.prototype.destroy = function () { + destroyLogger(this); + }; + + /** + * Emits the event. + * @param {String} eventName The name of the event. + * @param {*} data Information about the event. + * @param {Number} timeout Optional timeout for the ajax request in ms. + */ + NotesLogger.prototype.emit = function (eventName, data, timeout) { + var args = [eventName, data]; + this.log(eventName, data); + if (timeout) { + args.push(null, {'timeout': timeout}); + } + return Logger.log.apply(Logger, args); + }; + + return { + getLogger: function (id, mode) { + var logger = new NotesLogger(id, mode); + loggers.push(logger); + return logger; + }, + destroyLogger: destroyLogger + }; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/utils/template.js b/lms/static/js/edxnotes/utils/template.js new file mode 100644 index 0000000000..51b1bbdbf6 --- /dev/null +++ b/lms/static/js/edxnotes/utils/template.js @@ -0,0 +1,22 @@ +;(function (define, undefined) { +'use strict'; +define(['jquery', 'underscore'], function($, _) { + /** + * Loads the named template from the page, or logs an error if it fails. + * @param name The name of the template. + * @return The loaded template. + */ + var loadTemplate = function(name) { + var templateSelector = '#' + name + '-tpl', + templateText = $(templateSelector).text(); + if (!templateText) { + console.error('Failed to load ' + name + ' template'); + } + return _.template(templateText); + }; + + return { + loadTemplate: loadTemplate + }; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/note_group.js b/lms/static/js/edxnotes/views/note_group.js new file mode 100644 index 0000000000..ca79ae134d --- /dev/null +++ b/lms/static/js/edxnotes/views/note_group.js @@ -0,0 +1,70 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'gettext', 'underscore', 'backbone' +], function (gettext, _, Backbone) { + var NoteSectionView, NoteGroupView; + + NoteSectionView = Backbone.View.extend({ + tagName: 'section', + className: 'note-section', + id: function () { + return 'note-section-' + _.uniqueId(); + }, + template: _.template('

    <%- sectionName %>

    '), + + 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('

    <%- chapterName %>

    '), + + 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); diff --git a/lms/static/js/edxnotes/views/note_item.js b/lms/static/js/edxnotes/views/note_item.js new file mode 100644 index 0000000000..9488496c63 --- /dev/null +++ b/lms/static/js/edxnotes/views/note_item.js @@ -0,0 +1,71 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore','backbone', 'js/edxnotes/utils/template', + 'js/edxnotes/utils/logger' +], function ($, _, Backbone, templateUtils, NotesLogger) { + var NoteItemView = Backbone.View.extend({ + tagName: 'article', + className: 'note', + id: function () { + return 'note-' + _.uniqueId(); + }, + events: { + 'click .note-excerpt-more-link': 'moreHandler', + 'click .reference-unit-link': 'unitLinkHandler', + }, + + initialize: function (options) { + this.template = templateUtils.loadTemplate('note-item'); + this.logger = NotesLogger.getLogger('note_item', options.debug); + this.listenTo(this.model, 'change:is_expanded', this.render); + }, + + render: function () { + var context = this.getContext(); + this.$el.html(this.template(context)); + + return this; + }, + + getContext: function () { + return $.extend({ + message: this.model.getNoteText() + }, this.model.toJSON()); + }, + + toggleNote: function () { + var value = !this.model.get('is_expanded'); + this.model.set('is_expanded', value); + }, + + moreHandler: function (event) { + event.preventDefault(); + this.toggleNote(); + }, + + unitLinkHandler: function (event) { + var REQUEST_TIMEOUT = 2000; + event.preventDefault(); + this.logger.emit('edx.student_notes.used_unit_link', { + 'note_id': this.model.get('id'), + 'component_usage_id': this.model.get('usage_id') + }, REQUEST_TIMEOUT).always(_.bind(function () { + this.redirectTo(event.target.href); + }, this)); + }, + + redirectTo: function (uri) { + window.location = uri; + }, + + remove: function () { + this.logger.destroy(); + Backbone.View.prototype.remove.call(this); + return this; + } + }); + + return NoteItemView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js new file mode 100644 index 0000000000..2bf6e159c8 --- /dev/null +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -0,0 +1,94 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger', + 'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller', + 'js/edxnotes/plugins/events' +], function ($, _, Annotator, NotesLogger) { + var plugins = ['Auth', 'Store', 'Scroller', 'Events'], + getOptions, setupPlugins, updateHeaders, getAnnotator; + + /** + * Returns options for the annotator. + * @param {jQuery Element} The container element. + * @param {String} params.endpoint The endpoint of the store. + * @param {String} params.user User id of annotation owner. + * @param {String} params.usageId Usage Id of the component. + * @param {String} params.courseId Course id. + * @param {String} params.token An authentication token. + * @param {String} params.tokenUrl The URL to request the token from. + * @return {Object} Options. + **/ + getOptions = function (element, params) { + var defaultParams = { + user: params.user, + usage_id: params.usageId, + course_id: params.courseId + }, + prefix = params.endpoint.replace(/(.+)\/$/, '$1'); + + return { + auth: { + token: params.token, + tokenUrl: params.tokenUrl + }, + events: { + stringLimit: 300 + }, + store: { + prefix: prefix, + annotationData: defaultParams, + loadFromSearch: defaultParams, + urls: { + create: '/annotations/', + read: '/annotations/:id/', + update: '/annotations/:id/', + destroy: '/annotations/:id/', + search: '/search/' + } + } + }; + }; + + /** + * Setups plugins for the annotator. + * @param {Object} annotator An instance of the annotator. + * @param {Array} plugins A list of plugins for the annotator. + * @param {Object} options An options for the annotator. + **/ + setupPlugins = function (annotator, plugins, options) { + _.each(plugins, function(plugin) { + var settings = options[plugin.toLowerCase()]; + annotator.addPlugin(plugin, settings); + }, this); + }; + + /** + * Factory method that returns Annotator.js instantiates. + * @param {DOM Element} element The container element. + * @param {String} params.endpoint The endpoint of the store. + * @param {String} params.user User id of annotation owner. + * @param {String} params.usageId Usage Id of the component. + * @param {String} params.courseId Course id. + * @param {String} params.token An authentication token. + * @param {String} params.tokenUrl The URL to request the token from. + * @return {Object} An instance of Annotator.js. + **/ + getAnnotator = function (element, params) { + var el = $(element), + options = getOptions(el, params), + logger = NotesLogger.getLogger(element.id, params.debug), + annotator; + + annotator = el.annotator(options).data('annotator'); + setupPlugins(annotator, plugins, options); + annotator.logger = logger; + logger.log({'element': element, 'options': options}); + return annotator; + }; + + return { + factory: getAnnotator + }; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/notes_page.js b/lms/static/js/edxnotes/views/notes_page.js new file mode 100644 index 0000000000..aa39ce513a --- /dev/null +++ b/lms/static/js/edxnotes/views/notes_page.js @@ -0,0 +1,44 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'backbone', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs_list', + 'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/course_structure', + 'js/edxnotes/views/tabs/search_results' +], function ( + Backbone, TabsCollection, TabsListView, RecentActivityView, CourseStructureView, + SearchResultsView +) { + var NotesPageView = Backbone.View.extend({ + initialize: function (options) { + this.options = options; + this.tabsCollection = new TabsCollection(); + + this.recentActivityView = new RecentActivityView({ + el: this.el, + collection: this.collection, + tabsCollection: this.tabsCollection + }); + + this.courseStructureView = new CourseStructureView({ + el: this.el, + collection: this.collection, + tabsCollection: this.tabsCollection + }); + + this.searchResultsView = new SearchResultsView({ + el: this.el, + tabsCollection: this.tabsCollection, + debug: this.options.debug, + createTabOnInitialization: false + }); + + this.tabsView = new TabsListView({collection: this.tabsCollection}); + this.$('.tab-list') + .append(this.tabsView.render().$el) + .removeClass('is-hidden'); + } + }); + + return NotesPageView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/page_factory.js b/lms/static/js/edxnotes/views/page_factory.js new file mode 100644 index 0000000000..43ab4be3c3 --- /dev/null +++ b/lms/static/js/edxnotes/views/page_factory.js @@ -0,0 +1,25 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'js/edxnotes/collections/notes', 'js/edxnotes/views/notes_page' +], function ($, NotesCollection, NotesPageView) { + /** + * Factory method for the Notes page. + * @param {Object} params Params for the Notes page. + * @param {Array} params.notesList A list of note models. + * @param {Boolean} params.debugMode Enable the flag to see debug information. + * @param {String} params.endpoint The endpoint of the store. + * @return {Object} An instance of NotesPageView. + */ + return function (params) { + var collection = new NotesCollection(params.notesList); + + return new NotesPageView({ + el: $('.wrapper-student-notes').get(0), + collection: collection, + debug: params.debugMode, + endpoint: params.endpoint + }); + }; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/search_box.js b/lms/static/js/edxnotes/views/search_box.js new file mode 100644 index 0000000000..dbcc9d239c --- /dev/null +++ b/lms/static/js/edxnotes/views/search_box.js @@ -0,0 +1,161 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/utils/logger', + 'js/edxnotes/collections/notes' +], function ($, _, Backbone, gettext, NotesLogger, NotesCollection) { + var SearchBoxView = Backbone.View.extend({ + events: { + 'submit': 'submitHandler' + }, + + errorMessage: gettext('An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.'), + emptyFieldMessage: (function () { + var message = gettext('Please enter a term in the %(anchor_start)s search field%(anchor_end)s.'); + return interpolate(message, { + 'anchor_start': '', + 'anchor_end': '' + }, 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); diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js new file mode 100644 index 0000000000..3f7e3f861f --- /dev/null +++ b/lms/static/js/edxnotes/views/shim.js @@ -0,0 +1,197 @@ +;(function (define, undefined) { +'use strict'; +define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { + var _t = Annotator._t; + + /** + * We currently run JQuery 1.7.2 in Jasmine tests and LMS. + * AnnotatorJS 1.2.9. uses two calls to addBack (in the two functions + * 'isAnnotator' and 'onHighlightMouseover') which was only defined in + * JQuery 1.8.0. In LMS, it works without throwing an error because + * JQuery.UI 1.10.0 adds support to jQuery<1.8 by augmenting '$.fn' with + * that missing function. It is not the case for all Jasmine unit tests, + * so we add it here if necessary. + **/ + if (!$.fn.addBack) { + $.fn.addBack = function (selector) { + return this.add( + selector === null ? this.prevObject : this.prevObject.filter(selector) + ); + }; + } + + /** + * The original _setupDynamicStyle uses a very expensive call to + * Util.maxZIndex(...) that sets the z-index of .annotator-adder, + * .annotator-outer, .annotator-notice, .annotator-filter. We set these + * values in annotator.min.css instead and do nothing here. + */ + Annotator.prototype._setupDynamicStyle = function() { }; + + Annotator.frozenSrc = null; + + /** + * Modifies Annotator.Plugin.Auth.haveValidToken to make it work with a new + * token format. + **/ + Annotator.Plugin.Auth.prototype.haveValidToken = function() { + return ( + this._unsafeToken && + this._unsafeToken.sub && + this._unsafeToken.exp && + this._unsafeToken.iat && + this.timeToExpiry() > 0 + ); + }; + + /** + * Modifies Annotator.Plugin.Auth.timeToExpiry to make it work with a new + * token format. + **/ + Annotator.Plugin.Auth.prototype.timeToExpiry = function() { + var now = new Date().getTime() / 1000, + expiry = this._unsafeToken.exp, + timeToExpiry = expiry - now; + + return (timeToExpiry > 0) ? timeToExpiry : 0; + }; + + /** + * Modifies Annotator.highlightRange to add a "tabindex=0" attribute + * to the 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 = [ + '
  • ', + '', + '', + _t('View as webpage'), + '', + '', + '', + '', + '
  • ' + ].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); diff --git a/lms/static/js/edxnotes/views/tab_item.js b/lms/static/js/edxnotes/views/tab_item.js new file mode 100644 index 0000000000..4346a8c7c2 --- /dev/null +++ b/lms/static/js/edxnotes/views/tab_item.js @@ -0,0 +1,57 @@ +;(function (define, undefined) { +'use strict'; +define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'], +function (gettext, _, Backbone, templateUtils) { + var TabItemView = Backbone.View.extend({ + tagName: 'li', + className: 'tab', + activeClassName: 'is-active', + + events: { + 'click': 'selectHandler', + 'click a': function (event) { event.preventDefault(); }, + 'click .action-close': 'closeHandler' + }, + + initialize: function (options) { + this.template = templateUtils.loadTemplate('tab-item'); + this.$el.attr('id', this.model.get('identifier')); + this.listenTo(this.model, { + 'change:is_active': function (model, value) { + this.$el.toggleClass(this.activeClassName, value); + if (value) { + this.$('.tab-label').prepend($('', { + '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); diff --git a/lms/static/js/edxnotes/views/tab_panel.js b/lms/static/js/edxnotes/views/tab_panel.js new file mode 100644 index 0000000000..8ab9f24937 --- /dev/null +++ b/lms/static/js/edxnotes/views/tab_panel.js @@ -0,0 +1,54 @@ +;(function (define, undefined) { +'use strict'; +define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item'], +function (gettext, _, Backbone, NoteItemView) { + var TabPanelView = Backbone.View.extend({ + tagName: 'section', + className: 'tab-panel', + title: '', + titleTemplate: _.template('

    <%- text %>

    '), + 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); diff --git a/lms/static/js/edxnotes/views/tab_view.js b/lms/static/js/edxnotes/views/tab_view.js new file mode 100644 index 0000000000..1c064ad615 --- /dev/null +++ b/lms/static/js/edxnotes/views/tab_view.js @@ -0,0 +1,138 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'underscore', 'backbone', 'js/edxnotes/models/tab' +], function (_, Backbone, TabModel) { + var TabView = Backbone.View.extend({ + PanelConstructor: null, + + tabInfo: { + name: '', + class_name: '' + }, + + initialize: function (options) { + _.bindAll(this, 'showLoadingIndicator', 'hideLoadingIndicator'); + this.options = _.defaults(options || {}, { + createTabOnInitialization: true + }); + + if (this.options.createTabOnInitialization) { + this.createTab(); + } + }, + + /** + * Creates a tab for the view. + */ + createTab: function () { + this.tabModel = new TabModel(this.tabInfo); + this.options.tabsCollection.add(this.tabModel); + this.listenTo(this.tabModel, { + 'change:is_active': function (model, value) { + if (value) { + this.render(); + } else { + this.destroySubView(); + } + }, + 'destroy': function () { + this.destroySubView(); + this.tabModel = null; + this.onClose(); + } + }); + }, + + /** + * Renders content for the view. + */ + render: function () { + this.hideErrorMessage().showLoadingIndicator(); + // If the view is already rendered, destroy it. + this.destroySubView(); + this.renderContent().always(this.hideLoadingIndicator); + return this; + }, + + renderContent: function () { + this.contentView = this.getSubView(); + this.$('.wrapper-tabs').append(this.contentView.render().$el); + return $.Deferred().resolve().promise(); + }, + + getSubView: function () { + var collection = this.getCollection(); + return new this.PanelConstructor({collection: collection}); + }, + + destroySubView: function () { + if (this.contentView) { + this.contentView.remove(); + this.contentView = null; + } + }, + + /** + * Returns collection for the view. + * @return {Backbone.Collection} + */ + getCollection: function () { + return this.collection; + }, + + /** + * Callback that is called on closing the tab. + */ + onClose: function () { }, + + /** + * Returns the page's loading indicator. + */ + getLoadingIndicator: function() { + return this.$('.ui-loading'); + }, + + /** + * Shows the page's loading indicator. + */ + showLoadingIndicator: function() { + this.getLoadingIndicator().removeClass('is-hidden'); + return this; + }, + + /** + * Hides the page's loading indicator. + */ + hideLoadingIndicator: function() { + this.getLoadingIndicator().addClass('is-hidden'); + return this; + }, + + + /** + * Shows error message. + */ + showErrorMessage: function (message) { + this.$('.wrapper-msg') + .removeClass('is-hidden') + .find('.msg-content .copy').html(message); + + return this; + }, + + /** + * Hides error message. + */ + hideErrorMessage: function () { + this.$('.wrapper-msg') + .addClass('is-hidden') + .find('.msg-content .copy').html(''); + + return this; + } + }); + + return TabView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/tabs/course_structure.js b/lms/static/js/edxnotes/views/tabs/course_structure.js new file mode 100644 index 0000000000..9d1a5918dd --- /dev/null +++ b/lms/static/js/edxnotes/views/tabs/course_structure.js @@ -0,0 +1,56 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'gettext', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel', + 'js/edxnotes/views/tab_view' +], function (gettext, NoteGroupView, TabPanelView, TabView) { + var CourseStructureView = TabView.extend({ + PanelConstructor: TabPanelView.extend({ + id: 'structure-panel', + title: 'Location in Course', + + renderContent: function () { + var courseStructure = this.collection.getCourseStructure(), + container = document.createDocumentFragment(); + + _.each(courseStructure.chapters, function (chapterInfo) { + var group = this.getGroup(chapterInfo); + _.each(chapterInfo.children, function (location) { + var sectionInfo = courseStructure.sections[location], + section; + if (sectionInfo) { + section = group.addChild(sectionInfo); + _.each(sectionInfo.children, function (location) { + var notes = courseStructure.units[location]; + if (notes) { + section.addChild(this.getNotes(notes)) + } + }, this); + } + }, this); + container.appendChild(group.render().el); + }, this); + this.$el.append(container); + return this; + }, + + getGroup: function (chapter, section) { + var group = new NoteGroupView({ + chapter: chapter, + section: section + }); + this.children.push(group); + return group; + } + }), + + tabInfo: { + name: gettext('Location in Course'), + identifier: 'view-course-structure', + icon: 'fa fa-list-ul' + } + }); + + return CourseStructureView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/tabs/recent_activity.js b/lms/static/js/edxnotes/views/tabs/recent_activity.js new file mode 100644 index 0000000000..58a54c3403 --- /dev/null +++ b/lms/static/js/edxnotes/views/tabs/recent_activity.js @@ -0,0 +1,31 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view' +], function (gettext, TabPanelView, TabView) { + var RecentActivityView = TabView.extend({ + PanelConstructor: TabPanelView.extend({ + id: 'recent-panel', + title: 'Recent Activity', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' ') + }, + renderContent: function () { + this.$el.append(this.getNotes(this.collection.toArray())); + return this; + } + }), + + tabInfo: { + identifier: 'view-recent-activity', + name: gettext('Recent Activity'), + icon: 'fa fa-clock-o' + } + }); + + return RecentActivityView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/tabs/search_results.js b/lms/static/js/edxnotes/views/tabs/search_results.js new file mode 100644 index 0000000000..f9914e32fe --- /dev/null +++ b/lms/static/js/edxnotes/views/tabs/search_results.js @@ -0,0 +1,148 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view', + 'js/edxnotes/views/search_box' +], function (gettext, TabPanelView, TabView, SearchBoxView) { + var SearchResultsView = TabView.extend({ + PanelConstructor: TabPanelView.extend({ + id: 'search-results-panel', + title: 'Search Results', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' '); + }, + renderContent: function () { + this.$el.append(this.getNotes(this.collection.toArray())); + return this; + } + }), + + NoResultsViewConstructor: TabPanelView.extend({ + id: 'no-results-panel', + title: 'No results found', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' '); + }, + renderContent: function () { + var message = gettext('No results found for "%(query_string)s". Please try searching again.'); + + this.$el.append($('

    ', { + 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); diff --git a/lms/static/js/edxnotes/views/tabs_list.js b/lms/static/js/edxnotes/views/tabs_list.js new file mode 100644 index 0000000000..52d246dfbe --- /dev/null +++ b/lms/static/js/edxnotes/views/tabs_list.js @@ -0,0 +1,41 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'underscore', 'backbone', 'js/edxnotes/views/tab_item' +], function (_, Backbone, TabItemView) { + var TabsListView = Backbone.View.extend({ + tagName: 'ul', + className: 'tabs', + + initialize: function (options) { + this.options = options; + this.listenTo(this.collection, { + 'add': this.createTab, + 'destroy': function (model, collection) { + if (model.isActive() && collection.length) { + collection.at(0).activate(); + } + } + }); + }, + + render: function () { + this.collection.each(this.createTab, this); + if (this.collection.length) { + this.collection.at(0).activate(); + } + return this; + }, + + createTab: function (model) { + var tab = new TabItemView({ + model: model + }); + tab.render().$el.appendTo(this.$el); + return tab; + } + }); + + return TabsListView; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/toggle_notes_factory.js b/lms/static/js/edxnotes/views/toggle_notes_factory.js new file mode 100644 index 0000000000..c4f23ff841 --- /dev/null +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -0,0 +1,98 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore', 'backbone', 'gettext', + 'annotator', 'js/edxnotes/views/visibility_decorator' +], function($, _, Backbone, gettext, Annotator, EdxnotesVisibilityDecorator) { + var ToggleNotesView = Backbone.View.extend({ + events: { + 'click .action-toggle-notes': 'toggleHandler' + }, + + errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."), + + initialize: function (options) { + _.bindAll(this, 'onSuccess', 'onError'); + this.visibility = options.visibility; + this.visibilityUrl = options.visibilityUrl; + this.label = this.$('.utility-control-label'); + this.actionLink = this.$('.action-toggle-notes'); + this.actionLink.removeClass('is-disabled'); + this.actionToggleMessage = this.$('.action-toggle-message'); + this.notification = new Annotator.Notification(); + }, + + toggleHandler: function (event) { + event.preventDefault(); + this.visibility = !this.visibility; + this.showActionMessage(); + this.toggleNotes(this.visibility); + }, + + toggleNotes: function (visibility) { + if (visibility) { + this.enableNotes(); + } else { + this.disableNotes(); + } + this.sendRequest(); + }, + + showActionMessage: function () { + // The following lines are necessary to re-trigger the CSS animation on span.action-toggle-message + this.actionToggleMessage.removeClass('is-fleeting'); + this.actionToggleMessage.offset().width = this.actionToggleMessage.offset().width; + this.actionToggleMessage.addClass('is-fleeting'); + }, + + enableNotes: function () { + _.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote); + this.actionLink.addClass('is-active').attr('aria-pressed', true); + this.label.text(gettext('Hide notes')); + this.actionToggleMessage.text(gettext('Showing notes')); + }, + + disableNotes: function () { + EdxnotesVisibilityDecorator.disableNotes(); + this.actionLink.removeClass('is-active').attr('aria-pressed', false); + this.label.text(gettext('Show notes')); + this.actionToggleMessage.text(gettext('Hiding notes')); + }, + + hideErrorMessage: function() { + this.notification.hide(); + }, + + showErrorMessage: function(message) { + this.notification.show(message, Annotator.Notification.ERROR); + }, + + sendRequest: function () { + return $.ajax({ + type: 'PUT', + url: this.visibilityUrl, + dataType: 'json', + data: JSON.stringify({'visibility': this.visibility}), + success: this.onSuccess, + error: this.onError + }); + }, + + onSuccess: function () { + this.hideErrorMessage(); + }, + + onError: function () { + this.showErrorMessage(this.errorMessage); + } + }); + + return function (visibility, visibilityUrl) { + return new ToggleNotesView({ + el: $('.edx-notes-visibility').get(0), + visibility: visibility, + visibilityUrl: visibilityUrl + }); + }; +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/views/visibility_decorator.js b/lms/static/js/edxnotes/views/visibility_decorator.js new file mode 100644 index 0000000000..6436613f28 --- /dev/null +++ b/lms/static/js/edxnotes/views/visibility_decorator.js @@ -0,0 +1,74 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore', 'js/edxnotes/views/notes_factory' +], function($, _, NotesFactory) { + var parameters = {}, visibility = null, + getIds, createNote, cleanup, factory; + + getIds = function () { + return _.map($('.edx-notes-wrapper'), function (element) { + return element.id; + }); + }; + + createNote = function (element, params) { + if (params) { + return NotesFactory.factory(element, params); + } + return null; + }; + + cleanup = function (ids) { + var list = _.clone(Annotator._instances); + ids = ids || []; + + _.each(list, function (instance) { + var id = instance.element.attr('id'); + if (!_.contains(ids, id)) { + instance.destroy(); + } + }); + }; + + factory = function (element, params, isVisible) { + // When switching sequentials, we need to keep track of the + // parameters of each element and the visibility (that may have been + // changed by the checkbox). + parameters[element.id] = params; + + if (_.isNull(visibility)) { + visibility = isVisible; + } + + if (visibility) { + // When switching sequentials, the global object Annotator still + // keeps track of the previous instances that were created in an + // array called 'Annotator._instances'. We have to destroy these + // but keep those found on page being loaded (for the case when + // there are more than one HTMLcomponent per vertical). + cleanup(getIds()); + return createNote(element, params); + } + return null; + }; + + return { + factory: factory, + + enableNote: function (element) { + createNote(element, parameters[element.id]); + visibility = true; + }, + + disableNotes: function () { + cleanup(); + visibility = false; + }, + + _setVisibility: function (state) { + visibility = state; + }, + } +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html new file mode 100644 index 0000000000..538bd55cae --- /dev/null +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -0,0 +1,48 @@ +

    +
    +
    + +
    +
    +

    + Notes + Highlights and notes you've made in course content +

    +
    + + +
    + + + +
    + + +
    + + + + Loading +
    +
    + +
    +
    +
    diff --git a/lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html b/lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html new file mode 100644 index 0000000000..7c5a4481b4 --- /dev/null +++ b/lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html @@ -0,0 +1,6 @@ +
    +
    Annotate it!
    +
    +
    +
    Annotate it!
    +
    diff --git a/lms/static/js/fixtures/edxnotes/toggle_notes.html b/lms/static/js/fixtures/edxnotes/toggle_notes.html new file mode 100644 index 0000000000..7b1d2775a9 --- /dev/null +++ b/lms/static/js/fixtures/edxnotes/toggle_notes.html @@ -0,0 +1,7 @@ +
    + Hiding notes + +
    diff --git a/lms/static/js/spec/edxnotes/collections/notes_spec.js b/lms/static/js/spec/edxnotes/collections/notes_spec.js new file mode 100644 index 0000000000..f5dc0206a8 --- /dev/null +++ b/lms/static/js/spec/edxnotes/collections/notes_spec.js @@ -0,0 +1,34 @@ +define([ + 'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes' +], function(Helpers, NotesCollection) { + 'use strict'; + describe('EdxNotes NotesCollection', function() { + var notes = Helpers.getDefaultNotes(); + + beforeEach(function () { + this.collection = new NotesCollection(notes); + }); + + it('can return correct course structure', function () { + var structure = this.collection.getCourseStructure(); + + expect(structure.chapters).toEqual([ + Helpers.getChapter('First Chapter', 1, 0, [2]), + Helpers.getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]) + ]); + + expect(structure.sections).toEqual({ + 'i4x://section/0': Helpers.getSection('Third Section', 0, ['w_n', 1, 0]), + 'i4x://section/1': Helpers.getSection('Second Section', 1, [2]), + 'i4x://section/2': Helpers.getSection('First Section', 2, [3]) + }); + + expect(structure.units).toEqual({ + 'i4x://unit/0': [this.collection.at(0), this.collection.at(1)], + 'i4x://unit/1': [this.collection.at(2)], + 'i4x://unit/2': [this.collection.at(3)], + 'i4x://unit/3': [this.collection.at(4)] + }); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/custom_matchers.js b/lms/static/js/spec/edxnotes/custom_matchers.js new file mode 100644 index 0000000000..c5309bd6c9 --- /dev/null +++ b/lms/static/js/spec/edxnotes/custom_matchers.js @@ -0,0 +1,32 @@ +define(['jquery'], function($) { + 'use strict'; + return function (that) { + that.addMatchers({ + toContainText: function (text) { + var trimmedText = $.trim($(this.actual).text()); + + if (text && $.isFunction(text.test)) { + return text.test(trimmedText); + } else { + return trimmedText.indexOf(text) !== -1; + } + }, + + toHaveLength: function (number) { + return $(this.actual).length === number; + }, + + toHaveIndex: function (number) { + return $(this.actual).index() === number; + }, + + toBeInRange: function (min, max) { + return min <= this.actual && this.actual <= max; + }, + + toBeFocused: function () { + return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement; + } + }); + }; +}); diff --git a/lms/static/js/spec/edxnotes/helpers.js b/lms/static/js/spec/edxnotes/helpers.js new file mode 100644 index 0000000000..09cf2634b9 --- /dev/null +++ b/lms/static/js/spec/edxnotes/helpers.js @@ -0,0 +1,169 @@ +define(['underscore'], function(_) { + 'use strict'; + var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT, + base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes; + + LONG_TEXT = [ + 'Adipisicing elit, sed do eiusmod tempor incididunt ', + 'ut labore et dolore magna aliqua. Ut enim ad minim ', + 'veniam, quis nostrud exercitation ullamco laboris ', + 'nisi ut aliquip ex ea commodo consequat. Duis aute ', + 'irure dolor in reprehenderit in voluptate velit esse ', + 'cillum dolore eu fugiat nulla pariatur. Excepteur ', + 'sint occaecat cupidatat non proident, sunt in culpa ', + 'qui officia deserunt mollit anim id est laborum.' + ].join(''); + PRUNED_TEXT = [ + 'Adipisicing elit, sed do eiusmod tempor incididunt ', + 'ut labore et dolore magna aliqua. Ut enim ad minim ', + 'veniam, quis nostrud exercitation ullamco laboris ', + 'nisi ut aliquip ex ea commodo consequat. Duis aute ', + 'irure dolor in reprehenderit in voluptate velit esse ', + 'cillum dolore eu fugiat nulla pariatur...' + ].join(''); + TRUNCATED_TEXT = [ + 'Adipisicing elit, sed do eiusmod tempor incididunt ', + 'ut labore et dolore magna aliqua. Ut enim ad minim ', + 'veniam, quis nostrud exercitation ullamco laboris ', + 'nisi ut aliquip ex ea commodo consequat. Duis aute ', + 'irure dolor in reprehenderit in voluptate velit esse ', + 'cillum dolore eu fugiat nulla pariatur. Exce' + ].join(''); + SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt'; + + base64Encode = function (data) { + var ac, bits, enc, h1, h2, h3, h4, i, o1, o2, o3, r, tmp_arr; + if (btoa) { + // Gecko and Webkit provide native code for this + return btoa(data); + } else { + // Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_encode + // version 1109.2015 + i = 0; + ac = 0; + enc = ""; + tmp_arr = []; + if (!data) { + return data; + } + data += ''; + while (i < data.length) { + o1 = data.charCodeAt(i++); + o2 = data.charCodeAt(i++); + o3 = data.charCodeAt(i++); + bits = o1 << 16 | o2 << 8 | o3; + h1 = bits >> 18 & 0x3f; + h2 = bits >> 12 & 0x3f; + h3 = bits >> 6 & 0x3f; + h4 = bits & 0x3f; + tmp_arr[ac++] = B64.charAt(h1) + B64.charAt(h2) + B64.charAt(h3) + B64.charAt(h4); + } + enc = tmp_arr.join(''); + r = data.length % 3; + return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3); + } + }; + + makeToken = function() { + var now = (new Date()).getTime() / 1000, + rawToken = { + sub: "sub", + exp: now + 100, + iat: now + }; + + return 'header.' + base64Encode(JSON.stringify(rawToken)) + '.signature'; + }; + getChapter = function (name, location, index, children) { + return { + display_name: name, + location: 'i4x://chapter/' + location, + index: index, + children: _.map(children, function (i) { + return 'i4x://section/' + i; + }) + }; + }; + + getSection = function (name, location, children) { + return { + display_name: name, + location: 'i4x://section/' + location, + children: _.map(children, function (i) { + return 'i4x://unit/' + i; + }) + }; + }; + + getUnit = function (name, location) { + return { + display_name: name, + location: 'i4x://unit/' + location, + url: 'http://example.com' + }; + }; + + getDefaultNotes = function () { + return [ + { + chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]), + section: getSection('Third Section', 0, ['w_n', 1, 0]), + unit: getUnit('Fourth Unit', 0), + created: 'December 11, 2014 at 11:12AM', + updated: 'December 11, 2014 at 11:12AM', + text: 'Third added model', + quote: 'Note 4' + }, + { + chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]), + section: getSection('Third Section', 0, ['w_n', 1, 0]), + unit: getUnit('Fourth Unit', 0), + created: 'December 11, 2014 at 11:11AM', + updated: 'December 11, 2014 at 11:11AM', + text: 'Third added model', + quote: 'Note 5' + }, + { + chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]), + section: getSection('Third Section', 0, ['w_n', 1, 0]), + unit: getUnit('Third Unit', 1), + created: 'December 11, 2014 at 11:11AM', + updated: 'December 11, 2014 at 11:11AM', + text: 'Second added model', + quote: 'Note 3' + }, + { + chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]), + section: getSection('Second Section', 1, [2]), + unit: getUnit('Second Unit', 2), + created: 'December 11, 2014 at 11:10AM', + updated: 'December 11, 2014 at 11:10AM', + text: 'First added model', + quote: 'Note 2' + }, + { + chapter: getChapter('First Chapter', 1, 0, [2]), + section: getSection('First Section', 2, [3]), + unit: getUnit('First Unit', 3), + created: 'December 11, 2014 at 11:10AM', + updated: 'December 11, 2014 at 11:10AM', + text: 'First added model', + quote: 'Note 1' + } + ]; + }; + + return { + LONG_TEXT: LONG_TEXT, + PRUNED_TEXT: PRUNED_TEXT, + TRUNCATED_TEXT: TRUNCATED_TEXT, + SHORT_TEXT: SHORT_TEXT, + base64Encode: base64Encode, + makeToken: makeToken, + getChapter: getChapter, + getSection: getSection, + getUnit: getUnit, + getDefaultNotes: getDefaultNotes + }; +}); diff --git a/lms/static/js/spec/edxnotes/models/note_spec.js b/lms/static/js/spec/edxnotes/models/note_spec.js new file mode 100644 index 0000000000..285bb090d9 --- /dev/null +++ b/lms/static/js/spec/edxnotes/models/note_spec.js @@ -0,0 +1,34 @@ +define([ + 'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes' +], function(Helpers, NotesCollection) { + 'use strict'; + describe('EdxNotes NoteModel', function() { + beforeEach(function () { + this.collection = new NotesCollection([ + {quote: Helpers.LONG_TEXT}, + {quote: Helpers.SHORT_TEXT} + ]); + }); + + it('has correct values on initialization', function () { + expect(this.collection.at(0).get('is_expanded')).toBeFalsy(); + expect(this.collection.at(0).get('show_link')).toBeTruthy(); + expect(this.collection.at(1).get('is_expanded')).toBeFalsy(); + expect(this.collection.at(1).get('show_link')).toBeFalsy(); + }); + + it('can return appropriate note text', function () { + var model = this.collection.at(0); + + // is_expanded = false, show_link = true + expect(model.getNoteText()).toBe(Helpers.PRUNED_TEXT); + model.set('is_expanded', true); + // is_expanded = true, show_link = true + expect(model.getNoteText()).toBe(Helpers.LONG_TEXT); + model.set('show_link', false); + model.set('is_expanded', false); + // is_expanded = false, show_link = false + expect(model.getNoteText()).toBe(Helpers.LONG_TEXT); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/models/tab_spec.js b/lms/static/js/spec/edxnotes/models/tab_spec.js new file mode 100644 index 0000000000..00e410b80e --- /dev/null +++ b/lms/static/js/spec/edxnotes/models/tab_spec.js @@ -0,0 +1,33 @@ +define([ + 'js/edxnotes/collections/tabs' +], function(TabsCollection) { + 'use strict'; + describe('EdxNotes TabModel', function() { + beforeEach(function () { + this.collection = new TabsCollection([{}, {}, {}]); + }); + + it('when activate current model, all other models are inactivated', function () { + this.collection.at(1).activate(); + expect(this.collection.at(1).get('is_active')).toBeTruthy(); + expect(this.collection.at(0).get('is_active')).toBeFalsy(); + expect(this.collection.at(2).get('is_active')).toBeFalsy(); + }); + + it('can inactivate current model', function () { + var model = this.collection.at(0); + model.activate(); + expect(model.get('is_active')).toBeTruthy(); + model.inactivate(); + expect(model.get('is_active')).toBeFalsy(); + }); + + it('can see correct activity status via isActive', function () { + var model = this.collection.at(0); + model.activate(); + expect(model.isActive()).toBeTruthy(); + model.inactivate(); + expect(model.isActive()).toBeFalsy(); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/plugins/events_spec.js b/lms/static/js/spec/edxnotes/plugins/events_spec.js new file mode 100644 index 0000000000..852473a234 --- /dev/null +++ b/lms/static/js/spec/edxnotes/plugins/events_spec.js @@ -0,0 +1,158 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers', + 'annotator', 'logger', 'js/edxnotes/views/notes_factory' +], function($, _, AjaxHelpers, Helpers, Annotator, Logger, NotesFactory) { + 'use strict'; + describe('EdxNotes Events Plugin', function() { + var note = { + user: 'user-123', + id: 'note-123', + text: 'text-123', + quote: 'quote-123', + usage_id: 'usage-123' + }, + noteWithoutId = { + user: 'user-123', + text: 'text-123', + quote: 'quote-123', + usage_id: 'usage-123' + }; + + beforeEach(function() { + this.annotator = NotesFactory.factory( + $('
    ').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(); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/plugins/scroller_spec.js b/lms/static/js/spec/edxnotes/plugins/scroller_spec.js new file mode 100644 index 0000000000..b60eb86cb3 --- /dev/null +++ b/lms/static/js/spec/edxnotes/plugins/scroller_spec.js @@ -0,0 +1,94 @@ +define([ + 'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory', + 'js/spec/edxnotes/custom_matchers' +], function($, _, Annotator, NotesFactory, customMatchers) { + 'use strict'; + describe('EdxNotes Scroll Plugin', function() { + var annotators, highlights; + + function checkAnnotatorIsFrozen(annotator) { + expect(annotator.isFrozen).toBe(true); + expect(annotator.onHighlightMouseover).not.toHaveBeenCalled(); + expect(annotator.startViewerHideTimer).not.toHaveBeenCalled(); + } + + function checkAnnotatorIsUnfrozen(annotator) { + expect(annotator.isFrozen).toBe(false); + expect(annotator.onHighlightMouseover).toHaveBeenCalled(); + expect(annotator.startViewerHideTimer).toHaveBeenCalled(); + } + + beforeEach(function() { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); + annotators = [ + NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), { + endpoint: 'http://example.com/' + }), + NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), { + endpoint: 'http://example.com/' + }) + ]; + + highlights = _.map(annotators, function(annotator) { + spyOn(annotator, 'onHighlightClick').andCallThrough(); + spyOn(annotator, 'onHighlightMouseover').andCallThrough(); + spyOn(annotator, 'startViewerHideTimer').andCallThrough(); + return $('', { + '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 + ); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/utils/logger_spec.js b/lms/static/js/spec/edxnotes/utils/logger_spec.js new file mode 100644 index 0000000000..95430e41bb --- /dev/null +++ b/lms/static/js/spec/edxnotes/utils/logger_spec.js @@ -0,0 +1,124 @@ +define([ + 'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers' +], function(Logger, NotesLogger, customMatchers) { + 'use strict'; + describe('Edxnotes NotesLogger', function() { + var getLogger = function(id, mode) { + return NotesLogger.getLogger(id, mode); + }; + + beforeEach(function () { + spyOn(window.console, 'log'); + spyOn(window.console, 'error'); + spyOn(Logger, 'log'); + customMatchers(this); + }); + + it('keeps a correct history of logs', function() { + var logger = getLogger('id', 1), + logs, log; + + logger.log('A log type', 'A first log'); + logger.log('A log type', 'A second log'); + expect(window.console.log).toHaveBeenCalled(); + + logs = logger.getHistory(); + // Test first log + log = logs[0]; + expect(log[0]).toBe('log'); + expect(log[1][0]).toBe('id'); + expect(log[1][1]).toBe('A log type'); + expect(log[1][2]).toBe('A first log'); + + // Test second log + log = logs[1]; + expect(log[0]).toBe('log'); + expect(log[1][0]).toBe('id'); + expect(log[1][1]).toBe('A log type'); + expect(log[1][2]).toBe('A second log'); + }); + + it('keeps a correct history of errors', function() { + var logger = getLogger('id', 1), + logs, log; + logger.error('An error type', 'A first error'); + logger.error('An error type', 'A second error'); + expect(window.console.error).toHaveBeenCalled(); + + logs = logger.getHistory(); + // Test first error + log = logs[0]; + expect(log[0]).toBe('error'); + expect(log[1][0]).toBe('id'); + expect(log[1][1]).toBe('An error type'); + expect(log[1][2]).toBe('A first error'); + + // Test second error + log = logs[1]; + expect(log[0]).toBe('error'); + expect(log[1][0]).toBe('id'); + expect(log[1][1]).toBe('An error type'); + expect(log[1][2]).toBe('A second error'); + }); + + it('can destroy the logger', function() { + var logger = getLogger('id', 1), + logs; + + logger.log('A log type', 'A first log'); + logger.error('An error type', 'A first error'); + logs = logger.getHistory(); + expect(logs.length).toBe(2); + logger.destroy(); + logs = logger.getHistory(); + expect(logs.length).toBe(0); + }); + + it('do not store the history in silent mode', function() { + var logger = getLogger('id', 0), + logs; + logger.log('A log type', 'A first log'); + logger.error('An error type', 'A first error'); + logs = logger.getHistory(); + expect(logs.length).toBe(0); + }); + + it('do not show logs in the console in silent mode', function() { + var logger = getLogger('id', 0); + logger.log('A log type', 'A first log'); + logger.error('An error type', 'A first error'); + expect(window.console.log).not.toHaveBeenCalled(); + expect(window.console.error).not.toHaveBeenCalled(); + }); + + it('can use timers', function() { + var logger = getLogger('id', 1), + now, t0, logs, log; + + now = function () { + return (new Date()).getTime(); + }; + + t0 = now(); + logger.time('timer'); + while (now() - t0 < 200) {} + logger.timeEnd('timer'); + + logs = logger.getHistory(); + log = logs[0]; + expect(log[0]).toBe('log'); + expect(log[1][0]).toBe('id'); + expect(log[1][1]).toBe('timer'); + expect(log[1][2]).toBeInRange(180, 220); + expect(log[1][3]).toBe('ms'); + }); + + it('can emit an event properly', function () { + var logger = getLogger('id', 0); + logger.emit('event_name', {id: 'some_id'}) + expect(Logger.log).toHaveBeenCalledWith('event_name', { + id: 'some_id' + }); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/note_item_spec.js b/lms/static/js/spec/edxnotes/views/note_item_spec.js new file mode 100644 index 0000000000..ca1af97646 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -0,0 +1,81 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', + 'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'logger', + 'js/edxnotes/models/note', 'js/edxnotes/views/note_item', + 'js/spec/edxnotes/custom_matchers' +], function( + $, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView, + customMatchers +) { + 'use strict'; + describe('EdxNotes NoteItemView', function() { + var getView = function (model) { + model = new NoteModel(_.defaults(model || {}, { + id: 'id-123', + user: 'user-123', + usage_id: 'usage_id-123', + created: 'December 11, 2014 at 11:12AM', + updated: 'December 11, 2014 at 11:12AM', + text: 'Third added model', + quote: Helpers.LONG_TEXT, + unit: { + url: 'http://example.com/' + } + })); + + return new NoteItemView({model: model}).render(); + }; + + beforeEach(function() { + customMatchers(this); + TemplateHelpers.installTemplate('templates/edxnotes/note-item'); + spyOn(Logger, 'log').andCallThrough(); + }); + + it('can be rendered properly', function() { + var view = getView(), + unitLink = view.$('.reference-unit-link').get(0); + + expect(view.$el).toContain('.note-excerpt-more-link'); + expect(view.$el).toContainText(Helpers.PRUNED_TEXT); + expect(view.$el).toContainText('More'); + view.$('.note-excerpt-more-link').click(); + + expect(view.$el).toContainText(Helpers.LONG_TEXT); + expect(view.$el).toContainText('Less'); + + view = getView({quote: Helpers.SHORT_TEXT}); + expect(view.$el).not.toContain('.note-excerpt-more-link'); + expect(view.$el).toContainText(Helpers.SHORT_TEXT); + + expect(unitLink.hash).toBe('#id-123'); + }); + + it('should display update value and accompanying text', function() { + var view = getView(); + expect(view.$('.reference-title').last()).toContainText('Last Edited:'); + expect(view.$('.reference-meta').last()).toContainText('December 11, 2014 at 11:12AM'); + }); + + it('should log the edx.student_notes.used_unit_link event properly', function () { + var requests = AjaxHelpers.requests(this), + view = getView(); + spyOn(view, 'redirectTo'); + view.$('.reference-unit-link').click(); + expect(Logger.log).toHaveBeenCalledWith( + 'edx.student_notes.used_unit_link', + { + 'note_id': 'id-123', + 'component_usage_id': 'usage_id-123' + }, + null, + { + 'timeout': 2000 + } + ); + expect(view.redirectTo).not.toHaveBeenCalled(); + AjaxHelpers.respondWithJson(requests, {}); + expect(view.redirectTo).toHaveBeenCalledWith('http://example.com/#id-123'); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/notes_factory_spec.js b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js new file mode 100644 index 0000000000..e297d9305d --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js @@ -0,0 +1,43 @@ +define([ + 'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers', + 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' +], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) { + 'use strict'; + describe('EdxNotes NotesFactory', function() { + beforeEach(function() { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); + this.wrapper = document.getElementById('edx-notes-wrapper-123'); + }); + + afterEach(function () { + _.invoke(Annotator._instances, 'destroy'); + }); + + it('can initialize annotator correctly', function() { + var requests = AjaxHelpers.requests(this), + token = Helpers.makeToken(), + options = { + user: 'a user', + usage_id : 'an usage', + course_id: 'a course' + }, + annotator = NotesFactory.factory(this.wrapper, { + endpoint: '/test_endpoint', + user: 'a user', + usageId : 'an usage', + courseId: 'a course', + token: token, + tokenUrl: '/test_token_url' + }), + request = requests[0]; + + expect(requests).toHaveLength(1); + expect(request.requestHeaders['x-annotator-auth-token']).toBe(token); + expect(annotator.options.auth.tokenUrl).toBe('/test_token_url'); + expect(annotator.options.store.prefix).toBe('/test_endpoint'); + expect(annotator.options.store.annotationData).toEqual(options); + expect(annotator.options.store.loadFromSearch).toEqual(options); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/notes_page_spec.js b/lms/static/js/spec/edxnotes/views/notes_page_spec.js new file mode 100644 index 0000000000..28f18a814b --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/notes_page_spec.js @@ -0,0 +1,46 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers', + 'js/edxnotes/views/page_factory', 'js/spec/edxnotes/custom_matchers' +], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory, customMatchers) { + 'use strict'; + describe('EdxNotes NotesPage', function() { + var notes = Helpers.getDefaultNotes(); + + beforeEach(function() { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + TemplateHelpers.installTemplates([ + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' + ]); + this.view = new NotesFactory({notesList: notes}); + }); + + + it('should be displayed properly', function() { + var requests = AjaxHelpers.requests(this), + tab; + + expect(this.view.$('#view-search-results')).not.toExist(); + tab = this.view.$('#view-recent-activity'); + expect(tab).toHaveClass('is-active'); + expect(tab.index()).toBe(0); + + tab = this.view.$('#view-course-structure'); + expect(tab).toExist(); + expect(tab.index()).toBe(1); + + expect(this.view.$('.tab-panel')).toExist(); + + this.view.$('.search-notes-input').val('test_query'); + this.view.$('.search-notes-submit').click(); + AjaxHelpers.respondWithJson(requests, { + total: 0, + rows: [] + }); + expect(this.view.$('#view-search-results')).toHaveClass('is-active'); + expect(this.view.$('#view-recent-activity')).toExist(); + expect(this.view.$('#view-course-structure')).toExist(); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/search_box_spec.js b/lms/static/js/spec/edxnotes/views/search_box_spec.js new file mode 100644 index 0000000000..16ce97b6d8 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -0,0 +1,162 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/search_box', + 'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers) { + 'use strict'; + describe('EdxNotes SearchBoxView', function() { + var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled; + + getSearchBox = function (options) { + options = _.defaults(options || {}, { + el: $('#search-notes-form').get(0), + beforeSearchStart: jasmine.createSpy(), + search: jasmine.createSpy(), + error: jasmine.createSpy(), + complete: jasmine.createSpy() + }); + + return new SearchBoxView(options); + }; + + submitForm = function (searchBox, text) { + searchBox.$('.search-notes-input').val(text); + searchBox.$('.search-notes-submit').click(); + }; + + assertBoxIsEnabled = function (searchBox) { + expect(searchBox.$el).not.toHaveClass('is-looking'); + expect(searchBox.$('.search-notes-submit')).not.toHaveClass('is-disabled'); + expect(searchBox.isDisabled).toBeFalsy(); + }; + + assertBoxIsDisabled = function (searchBox) { + expect(searchBox.$el).toHaveClass('is-looking'); + expect(searchBox.$('.search-notes-submit')).toHaveClass('is-disabled'); + expect(searchBox.isDisabled).toBeTruthy(); + }; + + beforeEach(function () { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + spyOn(Logger, 'log'); + this.searchBox = getSearchBox(); + }); + + it('sends a request with proper information on submit the form', function () { + var requests = AjaxHelpers.requests(this), + form = this.searchBox.el, + request; + + submitForm(this.searchBox, 'test_text'); + request = requests[0]; + expect(request.method).toBe(form.method.toUpperCase()); + expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'})); + }); + + it('returns success result', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + expect(this.searchBox.options.beforeSearchStart).toHaveBeenCalledWith( + 'test_text' + ); + assertBoxIsDisabled(this.searchBox); + AjaxHelpers.respondWithJson(requests, { + total: 2, + rows: [null, null] + }); + assertBoxIsEnabled(this.searchBox); + expect(this.searchBox.options.search).toHaveBeenCalledWith( + jasmine.any(NotesCollection), 2, 'test_text' + ); + expect(this.searchBox.options.complete).toHaveBeenCalledWith( + 'test_text' + ); + }); + + it('should log the edx.student_notes.searched event properly', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + AjaxHelpers.respondWithJson(requests, { + total: 2, + rows: [null, null] + }); + + expect(Logger.log).toHaveBeenCalledWith('edx.student_notes.searched', { + 'number_of_results': 2, + 'search_string': 'test_text' + }); + }); + + it('returns default error message if received data structure is wrong', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + AjaxHelpers.respondWithJson(requests, {}); + expect(this.searchBox.options.error).toHaveBeenCalledWith( + 'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.', + 'test_text' + ); + expect(this.searchBox.options.complete).toHaveBeenCalledWith( + 'test_text' + ); + }); + + it('returns default error message if network error occurs', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + AjaxHelpers.respondWithError(requests); + expect(this.searchBox.options.error).toHaveBeenCalledWith( + 'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.', + 'test_text' + ); + expect(this.searchBox.options.complete).toHaveBeenCalledWith( + 'test_text' + ); + }); + + it('returns error message if server error occurs', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + assertBoxIsDisabled(this.searchBox); + + requests[0].respond( + 500, {'Content-Type': 'application/json'}, + JSON.stringify({ + error: 'test error message' + }) + ); + + assertBoxIsEnabled(this.searchBox); + expect(this.searchBox.options.error).toHaveBeenCalledWith( + 'test error message', + 'test_text' + ); + expect(this.searchBox.options.complete).toHaveBeenCalledWith( + 'test_text' + ); + }); + + it('does not send second request during current search', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, 'test_text'); + assertBoxIsDisabled(this.searchBox); + submitForm(this.searchBox, 'another_text'); + AjaxHelpers.respondWithJson(requests, { + total: 2, + rows: [null, null] + }); + assertBoxIsEnabled(this.searchBox); + expect(requests).toHaveLength(1); + }); + + it('returns error message if the field is empty', function () { + var requests = AjaxHelpers.requests(this); + submitForm(this.searchBox, ' '); + expect(requests).toHaveLength(0); + assertBoxIsEnabled(this.searchBox); + expect(this.searchBox.options.error).toHaveBeenCalledWith( + 'Please enter a term in the search field.', + ' ' + ); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/shim_spec.js b/lms/static/js/spec/edxnotes/views/shim_spec.js new file mode 100644 index 0000000000..3e6208b2e8 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/shim_spec.js @@ -0,0 +1,125 @@ +define([ + 'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory', 'jasmine-jquery' +], function($, _, Annotator, NotesFactory) { + 'use strict'; + describe('EdxNotes Shim', function() { + var annotators, highlights; + + function checkAnnotatorIsFrozen(annotator) { + expect(annotator.isFrozen).toBe(true); + expect(annotator.onHighlightMouseover).not.toHaveBeenCalled(); + expect(annotator.startViewerHideTimer).not.toHaveBeenCalled(); + } + + function checkAnnotatorIsUnfrozen(annotator) { + expect(annotator.isFrozen).toBe(false); + expect(annotator.onHighlightMouseover).toHaveBeenCalled(); + expect(annotator.startViewerHideTimer).toHaveBeenCalled(); + } + + function checkClickEventsNotBound(namespace) { + var events = $._data(document, 'events').click; + + _.each(events, function(event) { + expect(event.namespace.indexOf(namespace)).toBe(-1); + }); + } + + beforeEach(function() { + loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); + highlights = []; + annotators = [ + NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), { + endpoint: 'http://example.com/' + }), + NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), { + endpoint: 'http://example.com/' + }) + ]; + _.each(annotators, function(annotator, index) { + highlights.push($('').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 + ); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tab_item_spec.js b/lms/static/js/spec/edxnotes/views/tab_item_spec.js new file mode 100644 index 0000000000..696632f070 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tab_item_spec.js @@ -0,0 +1,54 @@ +define([ + 'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs', + 'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) { + 'use strict'; + describe('EdxNotes TabItemView', function() { + beforeEach(function () { + customMatchers(this); + TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); + this.collection = new TabsCollection([ + {identifier: 'first-item'}, + { + identifier: 'second-item', + is_closable: true, + icon: 'icon-class' + } + ]); + this.tabsList = new TabsListView({ + collection: this.collection + }).render(); + }); + + it('can contain an icon', function () { + var firstItem = this.tabsList.$('#first-item'), + secondItem = this.tabsList.$('#second-item'); + + expect(firstItem.find('.icon')).not.toExist(); + expect(secondItem.find('.icon')).toHaveClass('icon-class'); + }); + + it('can navigate between tabs', function () { + var firstItem = this.tabsList.$('#first-item'), + secondItem = this.tabsList.$('#second-item'); + + expect(firstItem).toHaveClass('is-active'); // first tab is active + expect(firstItem).toContainText('Current tab'); + expect(secondItem).not.toHaveClass('is-active'); // second tab is not active + expect(secondItem).not.toContainText('Current tab'); + secondItem.click(); + expect(firstItem).not.toHaveClass('is-active'); // first tab is not active + expect(firstItem).not.toContainText('Current tab'); + expect(secondItem).toHaveClass('is-active'); // second tab is active + expect(secondItem).toContainText('Current tab'); + }); + + it('can close the tab', function () { + var secondItem = this.tabsList.$('#second-item'); + + expect(this.tabsList.$('.tab')).toHaveLength(2); + secondItem.find('.action-close').click(); + expect(this.tabsList.$('.tab')).toHaveLength(1); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tab_view_spec.js b/lms/static/js/spec/edxnotes/views/tab_view_spec.js new file mode 100644 index 0000000000..11f8ec3d87 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -0,0 +1,117 @@ +define([ + 'jquery', 'backbone', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs', + 'js/edxnotes/views/tabs_list', 'js/edxnotes/views/tab_view', + 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function( + $, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView, customMatchers +) { + 'use strict'; + describe('EdxNotes TabView', function() { + var TestSubView = Backbone.View.extend({ + id: 'test-subview-panel', + className: 'tab-panel', + content: '

    test view content

    ', + 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('

    test view content

    '); + }); + + 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('

    test view content

    '); + }); + + 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('

    test view content

    '); + }); + + 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('

    test view content

    '); + 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 = '

    New content

    '; + view.render(); + expect(view.$('.wrapper-tabs')).toContainHtml('

    New content

    '); + expect(view.$('.wrapper-tabs')).not.toContainHtml('

    test view content

    '); + }); + + it('can show/hide error messages', function () { + var view = getView(this.tabsCollection), + errorHolder = view.$('.wrapper-msg'); + view.showErrorMessage('

    error message is here

    '); + expect(errorHolder).not.toHaveClass('is-hidden'); + expect(errorHolder.find('.copy')).toContainHtml('

    error message is here

    '); + + 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('

    error message is here

    '); + view.render(); + expect(errorHolder).toHaveClass('is-hidden'); + expect(errorHolder.find('.copy')).toBeEmpty(); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js new file mode 100644 index 0000000000..a9a8971b51 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js @@ -0,0 +1,67 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers', + 'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs', + 'js/edxnotes/views/tabs/course_structure', 'js/spec/edxnotes/custom_matchers', + 'jasmine-jquery' +], function( + $, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, CourseStructureView, + customMatchers +) { + 'use strict'; + describe('EdxNotes CourseStructureView', function() { + var notes = Helpers.getDefaultNotes(), + getView, getText; + + getText = function (selector) { + return $(selector).map(function () { + return _.trim($(this).text()); + }).toArray(); + }; + + getView = function (collection, tabsCollection, options) { + var view; + + options = _.defaults(options || {}, { + el: $('.wrapper-student-notes'), + collection: collection, + tabsCollection: tabsCollection, + }); + + view = new CourseStructureView(options); + tabsCollection.at(0).activate(); + + return view; + }; + + beforeEach(function () { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + TemplateHelpers.installTemplates([ + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' + ]); + + this.collection = new NotesCollection(notes); + this.tabsCollection = new TabsCollection(); + }); + + it('displays a tab and content with proper data and order', function () { + var view = getView(this.collection, this.tabsCollection), + chapters = getText('.course-title'), + sections = getText('.course-subtitle'), + notes = getText('.note-excerpt-p'); + + expect(this.tabsCollection).toHaveLength(1); + expect(this.tabsCollection.at(0).toJSON()).toEqual({ + name: 'Location in Course', + identifier: 'view-course-structure', + icon: 'fa fa-list-ul', + is_active: true, + is_closable: false + }); + expect(view.$('#structure-panel')).toExist(); + expect(chapters).toEqual(['First Chapter', 'Second Chapter']); + expect(sections).toEqual(['First Section', 'Second Section', 'Third Section']); + expect(notes).toEqual(['Note 1', 'Note 2', 'Note 3', 'Note 4', 'Note 5']); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js new file mode 100644 index 0000000000..8134539e8c --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js @@ -0,0 +1,76 @@ +define([ + 'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/notes', + 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/recent_activity', + 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function( + $, TemplateHelpers, NotesCollection, TabsCollection, RecentActivityView, customMatchers +) { + 'use strict'; + describe('EdxNotes RecentActivityView', function() { + var notes = [ + { + created: 'December 11, 2014 at 11:12AM', + updated: 'December 11, 2014 at 11:12AM', + text: 'Third added model', + quote: 'Should be listed first' + }, + { + created: 'December 11, 2014 at 11:11AM', + updated: 'December 11, 2014 at 11:11AM', + text: 'Second added model', + quote: 'Should be listed second' + }, + { + created: 'December 11, 2014 at 11:10AM', + updated: 'December 11, 2014 at 11:10AM', + text: 'First added model', + quote: 'Should be listed third' + } + ], getView; + + getView = function (collection, tabsCollection, options) { + var view; + + options = _.defaults(options || {}, { + el: $('.wrapper-student-notes'), + collection: collection, + tabsCollection: tabsCollection, + }); + + view = new RecentActivityView(options); + tabsCollection.at(0).activate(); + + return view; + }; + + beforeEach(function () { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + TemplateHelpers.installTemplates([ + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' + ]); + + this.collection = new NotesCollection(notes); + this.tabsCollection = new TabsCollection(); + }); + + it('displays a tab and content with proper data and order', function () { + var view = getView(this.collection, this.tabsCollection); + + expect(this.tabsCollection).toHaveLength(1); + expect(this.tabsCollection.at(0).toJSON()).toEqual({ + name: 'Recent Activity', + identifier: 'view-recent-activity', + icon: 'fa fa-clock-o', + is_active: true, + is_closable: false + }); + expect(view.$('#recent-panel')).toExist(); + expect(view.$('.note')).toHaveLength(3); + _.each(view.$('.note'), function(element, index) { + expect($('.note-comments', element)).toContainText(notes[index].text); + expect($('.note-excerpt', element)).toContainText(notes[index].quote); + }); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js new file mode 100644 index 0000000000..47ee9a6294 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -0,0 +1,205 @@ +define([ + 'jquery', 'js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers', + 'logger', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results', + 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function( + $, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView, + customMatchers +) { + 'use strict'; + describe('EdxNotes SearchResultsView', function() { + var notes = [ + { + created: 'December 11, 2014 at 11:12AM', + updated: 'December 11, 2014 at 11:12AM', + text: 'Third added model', + quote: 'Should be listed first' + }, + { + created: 'December 11, 2014 at 11:11AM', + updated: 'December 11, 2014 at 11:11AM', + text: 'Second added model', + quote: 'Should be listed second' + }, + { + created: 'December 11, 2014 at 11:10AM', + updated: 'December 11, 2014 at 11:10AM', + text: 'First added model', + quote: 'Should be listed third' + } + ], + responseJson = { + total: 3, + rows: notes + }, + getView, submitForm; + + getView = function (tabsCollection, options) { + options = _.defaults(options || {}, { + el: $('.wrapper-student-notes'), + tabsCollection: tabsCollection, + user: 'test_user', + courseId: 'course_id', + createTabOnInitialization: false + }); + return new SearchResultsView(options); + }; + + submitForm = function (searchBox, text) { + searchBox.$('.search-notes-input').val(text); + searchBox.$('.search-notes-submit').click(); + }; + + beforeEach(function () { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + TemplateHelpers.installTemplates([ + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' + ]); + + this.tabsCollection = new TabsCollection(); + }); + + it('does not create a tab and content on initialization', function () { + var view = getView(this.tabsCollection); + expect(this.tabsCollection).toHaveLength(0); + expect(view.$('#search-results-panel')).not.toExist(); + }); + + it('displays a tab and content on search with proper data and order', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + submitForm(view.searchBox, 'second'); + AjaxHelpers.respondWithJson(requests, responseJson); + + expect(this.tabsCollection).toHaveLength(1); + expect(this.tabsCollection.at(0).toJSON()).toEqual({ + name: 'Search Results', + identifier: 'view-search-results', + icon: 'fa fa-search', + is_active: true, + is_closable: true + }); + expect(view.$('#search-results-panel')).toExist(); + expect(view.$('#search-results-panel')).toBeFocused(); + expect(view.$('.note')).toHaveLength(3); + view.searchResults.collection.each(function (model, index) { + expect(model.get('text')).toBe(notes[index].text); + }); + }); + + it('displays loading indicator when search is running', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + submitForm(view.searchBox, 'test query'); + expect(view.$('.ui-loading')).not.toHaveClass('is-hidden'); + expect(view.$('.ui-loading')).toBeFocused(); + expect(this.tabsCollection).toHaveLength(1); + expect(view.searchResults).toBeNull(); + expect(view.$('.tab-panel')).not.toExist(); + AjaxHelpers.respondWithJson(requests, responseJson); + expect(view.$('.ui-loading')).toHaveClass('is-hidden'); + }); + + it('displays no results message', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + submitForm(view.searchBox, 'some text'); + AjaxHelpers.respondWithJson(requests, { + total: 0, + rows: [] + }); + + expect(view.$('#search-results-panel')).not.toExist(); + expect(view.$('#no-results-panel')).toBeFocused(); + expect(view.$('#no-results-panel')).toExist(); + expect(view.$('#no-results-panel')).toContainText( + 'No results found for "some text".' + ); + }); + + it('does not send an additional request on switching between tabs', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + spyOn(Logger, 'log'); + submitForm(view.searchBox, 'test_query'); + AjaxHelpers.respondWithJson(requests, responseJson); + + expect(requests).toHaveLength(1); + + this.tabsCollection.add({}); + this.tabsCollection.at(1).activate(); + expect(view.$('#search-results-panel')).not.toExist(); + this.tabsCollection.at(0).activate(); + + expect(requests).toHaveLength(1); + expect(view.$('#search-results-panel')).toExist(); + expect(view.$('.note')).toHaveLength(3); + }); + + it('can clear search results if tab is closed', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + submitForm(view.searchBox, 'test_query'); + AjaxHelpers.respondWithJson(requests, responseJson); + expect(view.searchResults).toBeDefined(); + this.tabsCollection.at(0).destroy(); + expect(view.searchResults).toBeNull(); + }); + + it('can correctly show/hide error messages', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this); + + submitForm(view.searchBox, 'test error'); + requests[0].respond( + 500, {'Content-Type': 'application/json'}, + JSON.stringify({ + error: 'test error message' + }) + ); + + expect(view.$('.wrapper-msg')).not.toHaveClass('is-hidden'); + expect(view.$('.wrapper-msg .copy')).toContainText('test error message'); + expect(view.$('.ui-loading')).toHaveClass('is-hidden'); + + submitForm(view.searchBox, 'Second'); + AjaxHelpers.respondWithJson(requests, responseJson); + + expect(view.$('.wrapper-msg')).toHaveClass('is-hidden'); + expect(view.$('.wrapper-msg .copy')).toBeEmpty(); + }); + + it('can correctly update search results', function () { + var view = getView(this.tabsCollection), + requests = AjaxHelpers.requests(this), + newNotes = [{ + created: 'December 11, 2014 at 11:10AM', + updated: 'December 11, 2014 at 11:10AM', + text: 'New Note', + quote: 'New Note' + }]; + + submitForm(view.searchBox, 'test_query'); + AjaxHelpers.respondWithJson(requests, responseJson); + + expect(view.$('.note')).toHaveLength(3); + + submitForm(view.searchBox, 'new_test_query'); + AjaxHelpers.respondWithJson(requests, { + total: 1, + rows: newNotes + }); + + expect(view.$('.note').length).toHaveLength(1); + view.searchResults.collection.each(function (model, index) { + expect(model.get('text')).toBe(newNotes[index].text); + }); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/tabs_list_spec.js b/lms/static/js/spec/edxnotes/views/tabs_list_spec.js new file mode 100644 index 0000000000..32d131d82b --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/tabs_list_spec.js @@ -0,0 +1,50 @@ +define([ + 'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs', + 'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) { + 'use strict'; + describe('EdxNotes TabsListView', function() { + beforeEach(function () { + customMatchers(this); + TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); + this.collection = new TabsCollection([ + {identifier: 'first-item'}, + {identifier: 'second-item'} + ]); + this.tabsList = new TabsListView({ + collection: this.collection + }).render(); + }); + + it('has correct order and class names', function () { + var firstItem = this.tabsList.$('#first-item'), + secondItem = this.tabsList.$('#second-item'); + + expect(firstItem).toHaveIndex(0); + expect(firstItem).toHaveClass('is-active'); + expect(secondItem).toHaveIndex(1); + }); + + it('can add a new tab', function () { + var firstItem = this.tabsList.$('#first-item'), + thirdItem; + + this.collection.add({identifier: 'third-item'}); + thirdItem = this.tabsList.$('#third-item'); + + expect(firstItem).toHaveClass('is-active'); // first tab is still active + expect(thirdItem).toHaveIndex(2); + expect(this.tabsList.$('.tab')).toHaveLength(3); + }); + + it('can remove tabs', function () { + var secondItem = this.tabsList.$('#second-item'); + + this.collection.at(0).destroy(); // remove first tab + expect(this.tabsList.$('.tab')).toHaveLength(1); + expect(secondItem).toHaveClass('is-active'); // second tab becomes active + this.collection.at(0).destroy(); + expect(this.tabsList.$('.tab')).toHaveLength(0); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js new file mode 100644 index 0000000000..fa1e8aee2a --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js @@ -0,0 +1,99 @@ +define([ + 'jquery', 'annotator', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/visibility_decorator', + 'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/helpers', + 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' +], function( + $, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, Helpers, + customMatchers +) { + 'use strict'; + describe('EdxNotes ToggleNotesFactory', function() { + var params = { + endpoint: '/test_endpoint', + user: 'a user', + usageId : 'an usage', + courseId: 'a course', + token: Helpers.makeToken(), + tokenUrl: '/test_token_url' + }; + + beforeEach(function() { + customMatchers(this); + loadFixtures( + 'js/fixtures/edxnotes/edxnotes_wrapper.html', + 'js/fixtures/edxnotes/toggle_notes.html' + ); + VisibilityDecorator.factory( + document.getElementById('edx-notes-wrapper-123'), params, true + ); + VisibilityDecorator.factory( + document.getElementById('edx-notes-wrapper-456'), params, true + ); + this.toggleNotes = ToggleNotesFactory(true, '/test_url'); + this.button = $('.action-toggle-notes'); + this.label = this.button.find('.utility-control-label'); + this.toggleMessage = $('.action-toggle-message'); + }); + + afterEach(function () { + VisibilityDecorator._setVisibility(null); + _.invoke(Annotator._instances, 'destroy'); + $('.annotator-notice').remove(); + }); + + it('can toggle notes', function() { + var requests = AjaxHelpers.requests(this); + + expect(this.button).not.toHaveClass('is-disabled'); + expect(this.label).toContainText('Hide notes'); + expect(this.button).toHaveClass('is-active'); + expect(this.button).toHaveAttr('aria-pressed', 'true'); + expect(this.toggleMessage).not.toHaveClass('is-fleeting'); + expect(this.toggleMessage).toContainText('Hiding notes'); + + this.button.click(); + expect(this.label).toContainText('Show notes'); + expect(this.button).not.toHaveClass('is-active'); + expect(this.button).toHaveAttr('aria-pressed', 'false'); + expect(this.toggleMessage).toHaveClass('is-fleeting'); + expect(this.toggleMessage).toContainText('Hiding notes'); + expect(Annotator._instances).toHaveLength(0); + + AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', { + 'visibility': false + }); + AjaxHelpers.respondWithJson(requests, {}); + + this.button.click(); + expect(this.label).toContainText('Hide notes'); + expect(this.button).toHaveClass('is-active'); + expect(this.button).toHaveAttr('aria-pressed', 'true'); + expect(this.toggleMessage).toHaveClass('is-fleeting'); + expect(this.toggleMessage).toContainText('Showing notes'); + expect(Annotator._instances).toHaveLength(2); + + AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', { + 'visibility': true + }); + AjaxHelpers.respondWithJson(requests, {}); + }); + + it('can handle errors', function() { + var requests = AjaxHelpers.requests(this), + errorContainer = $('.annotator-notice'); + + this.button.click(); + AjaxHelpers.respondWithError(requests); + expect(errorContainer).toContainText( + "An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page." + ); + expect(errorContainer).toBeVisible(); + expect(errorContainer).toHaveClass('annotator-notice-show'); + expect(errorContainer).toHaveClass('annotator-notice-error'); + + this.button.click(); + AjaxHelpers.respondWithJson(requests, {}); + expect(errorContainer).not.toHaveClass('annotator-notice-show'); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js new file mode 100644 index 0000000000..ad63f78c4d --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js @@ -0,0 +1,56 @@ +define([ + 'annotator', 'js/edxnotes/views/visibility_decorator', + 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' +], function(Annotator, VisibilityDecorator, Helpers, customMatchers) { + 'use strict'; + describe('EdxNotes VisibilityDecorator', function() { + var params = { + endpoint: '/test_endpoint', + user: 'a user', + usageId : 'an usage', + courseId: 'a course', + token: Helpers.makeToken(), + tokenUrl: '/test_token_url' + }; + + beforeEach(function() { + customMatchers(this); + loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); + this.wrapper = document.getElementById('edx-notes-wrapper-123'); + }); + + afterEach(function () { + VisibilityDecorator._setVisibility(null); + _.invoke(Annotator._instances, 'destroy'); + }); + + it('can initialize Notes if it visibility equals True', function() { + var note = VisibilityDecorator.factory(this.wrapper, params, true); + expect(note).toEqual(jasmine.any(Annotator)); + }); + + it('does not initialize Notes if it visibility equals False', function() { + var note = VisibilityDecorator.factory(this.wrapper, params, false); + expect(note).toBeNull(); + }); + + it('can disable all notes', function() { + VisibilityDecorator.factory(this.wrapper, params, true); + VisibilityDecorator.factory(document.getElementById('edx-notes-wrapper-456'), params, true); + + VisibilityDecorator.disableNotes(); + expect(Annotator._instances).toHaveLength(0); + }); + + it('can enable the note', function() { + var secondWrapper = document.getElementById('edx-notes-wrapper-456'); + VisibilityDecorator.factory(this.wrapper, params, false); + VisibilityDecorator.factory(secondWrapper, params, false); + + VisibilityDecorator.enableNote(this.wrapper); + expect(Annotator._instances).toHaveLength(1); + VisibilityDecorator.enableNote(secondWrapper); + expect(Annotator._instances).toHaveLength(2); + }); + }); +}); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 49de0cb6c8..d199697b44 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -1,5 +1,4 @@ (function(requirejs, define) { - // TODO: how can we share the vast majority of this config that is in common with CMS? requirejs.config({ paths: { @@ -54,6 +53,7 @@ 'xblock/lms.runtime.v1': 'coffee/src/xblock/lms.runtime.v1', 'capa/display': 'xmodule_js/src/capa/display', 'string_utils': 'xmodule_js/common_static/js/src/string_utils', + 'logger': 'xmodule_js/common_static/js/src/logger', // Manually specify LMS files that are not converted to RequireJS 'history': 'js/vendor/history', @@ -77,7 +77,10 @@ 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', 'js/student_account/views/AccessView': 'js/student_account/views/AccessView', - 'js/student_profile/profile': 'js/student_profile/profile' + 'js/student_profile/profile': 'js/student_profile/profile', + + // edxnotes + 'annotator': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' }, shim: { 'gettext': { @@ -211,6 +214,9 @@ 'xmodule': { exports: 'XModule' }, + 'logger': { + exports: 'Logger' + }, 'sinon': { exports: 'sinon' }, @@ -488,6 +494,11 @@ 'js/verify_student/views/enrollment_confirmation_step_view' ] }, + // Student Notes + 'annotator': { + exports: 'Annotator', + deps: ['jquery'] + } } }); @@ -514,7 +525,26 @@ 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/review_photos_step_view_spec.js', - 'lms/include/js/spec/verify_student/make_payment_step_view_spec.js' + 'lms/include/js/spec/verify_student/make_payment_step_view_spec.js', + 'lms/include/js/spec/edxnotes/utils/logger_spec.js', + 'lms/include/js/spec/edxnotes/views/notes_factory_spec.js', + 'lms/include/js/spec/edxnotes/views/shim_spec.js', + 'lms/include/js/spec/edxnotes/views/note_item_spec.js', + 'lms/include/js/spec/edxnotes/views/notes_page_spec.js', + 'lms/include/js/spec/edxnotes/views/search_box_spec.js', + 'lms/include/js/spec/edxnotes/views/tabs_list_spec.js', + 'lms/include/js/spec/edxnotes/views/tab_item_spec.js', + 'lms/include/js/spec/edxnotes/views/tab_view_spec.js', + 'lms/include/js/spec/edxnotes/views/tabs/search_results_spec.js', + 'lms/include/js/spec/edxnotes/views/tabs/recent_activity_spec.js', + 'lms/include/js/spec/edxnotes/views/tabs/course_structure_spec.js', + 'lms/include/js/spec/edxnotes/views/visibility_decorator_spec.js', + 'lms/include/js/spec/edxnotes/views/toggle_notes_factory_spec.js', + 'lms/include/js/spec/edxnotes/models/tab_spec.js', + 'lms/include/js/spec/edxnotes/models/note_spec.js', + 'lms/include/js/spec/edxnotes/plugins/events_spec.js', + 'lms/include/js/spec/edxnotes/plugins/scroller_spec.js', + 'lms/include/js/spec/edxnotes/collections/notes_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index a1635660b1..5dfa944db2 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -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: diff --git a/lms/static/js_test_coffee.yml b/lms/static/js_test_coffee.yml index 4e257f9e66..60687bd41a 100644 --- a/lms/static/js_test_coffee.yml +++ b/lms/static/js_test_coffee.yml @@ -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 diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index dc078d28f9..f515351e19 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -1,8 +1,29 @@ -;(function (require, define, _) { +;(function (require, define) { var paths = {}, config; - // URI, tinymce, or jquery.tinymce may already have been loaded before the OVA templates and we do not want to load - // them a second time. Check if it is the case and use the global var in requireJS config. + // jquery, underscore, gettext, URI, tinymce, or jquery.tinymce may already + // have been loaded and we do not want to load them a second time. Check if + // it is the case and use the global var instead. + if (window.jQuery) { + define("jquery", [], function() {return window.jQuery;}); + } else { + paths.jquery = "js/vendor/jquery.min"; + } + if (window._) { + define("underscore", [], function() {return window._;}); + } else { + paths.jquery = "js/vendor/underscore-min"; + } + if (window.gettext) { + define("gettext", [], function() {return window.gettext;}); + } else { + paths.gettext = "/i18n"; + } + if (window.Logger) { + define("logger", [], function() {return window.Logger;}); + } else { + paths.logger = "js/src/logger"; + } if (window.URI) { define("URI", [], function() {return window.URI;}); } else { @@ -20,10 +41,14 @@ } config = { - // NOTE: baseUrl has been previously set in lms/templates/main.html + // NOTE: baseUrl has been previously set in lms/static/templates/main.html waitSeconds: 60, paths: { - // Files only needed for OVA + "annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min", + "date": "js/vendor/date", + "backbone": "js/vendor/backbone-min", + "underscore.string": "js/vendor/underscore.string.min", + // Files needed by OVA "annotator": "js/vendor/ova/annotator-full", "annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth", "video.dev": "js/vendor/ova/video.dev", @@ -42,10 +67,30 @@ "ova": 'js/vendor/ova/ova', "catch": 'js/vendor/ova/catch/js/catch', "handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2', - // end of files only needed for OVA + // end of files needed by OVA }, shim: { - // The following are all needed for OVA + "annotator_1.2.9": { + deps: ["jquery"], + exports: "Annotator" + }, + "date": { + exports: "Date" + }, + "jquery": { + exports: "$" + }, + "underscore": { + exports: "_" + }, + "backbone": { + deps: ["underscore", "jquery"], + exports: "Backbone" + }, + "logger": { + exports: "Logger" + }, + // Needed by OVA "video.dev": { exports:"videojs" }, @@ -74,7 +119,7 @@ deps: ["annotator"] }, "diacritic-annotator": { - deps: ["annotator"] + deps: ["annotator"] }, "flagging-annotator": { deps: ["annotator"] @@ -99,9 +144,19 @@ "URI" ] }, - // End of OVA + // End of needed by OVA + }, + map: { + "js/edxnotes/*": { + "annotator": "annotator_1.2.9" + } } }; - _.extend(config.paths, paths); + + for (var key in paths) { + if ({}.hasOwnProperty.call(paths, key)) { + config.paths[key] = paths[key]; + } + } require.config(config); -}).call(this, require || RequireJS.require, define || RequireJS.define, _); +}).call(this, require || RequireJS.require, define || RequireJS.define); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 25da3cbbf0..586a7fcd0b 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -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); + } +} diff --git a/lms/static/sass/base/_animations.scss b/lms/static/sass/base/_animations.scss index 28d5d770da..916c6be114 100644 --- a/lms/static/sass/base/_animations.scss +++ b/lms/static/sass/base/_animations.scss @@ -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; + } +} diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 0cbca47152..3c96ff9af4 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -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; + } +} diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 025591b4e6..2d73b00afc 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -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; diff --git a/lms/static/sass/course-rtl.scss.mako b/lms/static/sass/course-rtl.scss.mako index 05eaee88e8..e9d4d2930a 100644 --- a/lms/static/sass/course-rtl.scss.mako +++ b/lms/static/sass/course-rtl.scss.mako @@ -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"; diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index eb35050fe9..0fc53f0adf 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -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"; diff --git a/lms/static/sass/course/_student-notes.scss b/lms/static/sass/course/_student-notes.scss new file mode 100644 index 0000000000..2ec0a96369 --- /dev/null +++ b/lms/static/sass/course/_student-notes.scss @@ -0,0 +1,398 @@ +// 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; + } + } +} diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/modules/_calculator.scss similarity index 89% rename from lms/static/sass/course/layout/_calculator.scss rename to lms/static/sass/course/modules/_calculator.scss index c873b4cd7c..41dde8e17c 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/modules/_calculator.scss @@ -1,3 +1,6 @@ +// 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; diff --git a/lms/static/sass/course/layout/_chat.scss b/lms/static/sass/course/modules/_chat.scss similarity index 93% rename from lms/static/sass/course/layout/_chat.scss rename to lms/static/sass/course/modules/_chat.scss index b9724ef4b4..eb5bc4e5c6 100644 --- a/lms/static/sass/course/layout/_chat.scss +++ b/lms/static/sass/course/modules/_chat.scss @@ -1,5 +1,5 @@ -/* Chat --------------------------------------------------- */ +// LMS -- modules -- chat +// ==================== #chat-wrapper { position: fixed; bottom: 0; diff --git a/lms/static/sass/course/modules/_student-notes.scss b/lms/static/sass/course/modules/_student-notes.scss new file mode 100644 index 0000000000..3a10c85451 --- /dev/null +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -0,0 +1,290 @@ +// 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/PkijPpwAABBRSURBVHja7JsJVBRXFoarq5tNQZZWo6BxTRQXNOooxhWQBLcYlwRkMirmOKMnmVFHUcYdDUp0Yo5OopM4cQM1TlyjUSFGwIUWFQUjatxNQEFEFtnX+W/7Sovqqt7w5EwMdc6ltldf3/fevffderxSZWVlZbi5uTXh6rAVFBTkqbVubl07eno2d3BwaGgtZNPGjYf5wsLCDRu/+ir20aNH2dZCcnNzN6uPHTv2S2xsbHZaWpqLJZqJIR9FRMTxdHFJeHiiJZrl5+fniiF0jRdumgsjyOZNm44AshHPxAnXeXEhUzAJJEF8j5cWVoIZg9CmqqiokK3CksWLX3d0dJwy+f3331Cr1RoliEajMQ4Sw2xsbHglTZ6CampquOex8dxz2l5gkEY4qKyslOu1Qa6urpPRs9VkW2RjFmskQCaFhASQLZEZkDlYBBJDnJ2dXSnwmYLxpiDCdVMw3hyIObCnlr1g/nwfQCYpQcQbOTM5tbgDeDEkZPLkoaYgSpqpKysqnkIaNWrkYq7dUEim0EwhmkI1bw1ETjNVTk7OA2sg0jarDyO/ZhiJjtpS4923L1dWVs5VV1vW8Dyv4uzsbLnkc+c4dceOnn1LS0vat23bhnvSgypOpTItajXP2dvbcefOneVSL146ys+dOzvgyuWrMadOJeKGrb6AeRBb7syZM1xqyo9HwfDncZ0L+0dowGXATpw4qVfVGEyAJCUBkvrjUTzrTwzUkirDcfOewk5w9oBp8AD9iljoGt07rTvNpaRcPDqPIOx5+mlOkPnz5wakpV2JiU84ztlRNTVqTsXzeuHValyz4xJ1Ou4CICjrL37WoPsXLAgD7HJMXFw8Z2ur4dT8E23s7Wy4UydPchcupB5FGX8ZOxKUeyYLF84LSLt0OebYsXi9ZvYOdtwJBsE9f7lnVAUFuYp2smxpxJFOnTu9aWtry6VcSDm6cNF8f6WyRkEMFg7rclq0aP7fjZWrDyNmeL9c8iDedu7YMRK7xoHjx28y2tjGcsivt29PaOTsPNAGeSIGidNBwcF9La6aAPH18+UG+QzmtFqtN67pLALt2LYtAUOUHoLMWO/1BMM45o17OgUQ2dEz2R4drYf4AMLzakTNahY5n8FQRid9rpZG26KiE5ypOkP89JqIjZWOVSqeG+zrw7lp3bxRVidbteitUQnOLtQmhhApzMfXFzCtN57R1QJFbdkKiMtAP0Ao7lB16CE5oXtUTYJRB+BZPUzd6uWXE1xcXQcO8R+iqIms3aADWrdpw2VmZrbQJeoCeBdoYinkWTVVHNVC21jrrSopKakh67Y2ChCMXmw0xizbXM2I8dyc9gUObBpTBTw8WqixGw45n5GRnl4XjaZD9kP+DaibVSA8OAu7SHZKWm3GtTYWgfDATOxWQGxElynsepkNAoSq808JhII7DZKHzWpsQGYwiPhHyPzD0NifmtVGrE1WUlSQaDIXkNVm2REgc1jDiqtTBQk1pkmtqgEyCLu/SqpKkFmArDHLsgGxw57euaiXIkSQOeZCBI1egtCs324IxVGy3s9NtYkcqCtkGBtXHkLeAyTBGl8rZPZxCfIAkNIXLB6h9/4A6a/gMv0hvUyCUKgLdlsoXODYXwJ5E7sDzPM7G7OjPtjvgnjSizNkqwDDPoD9AL08E2QXaa7Ua40gLUTXmkHW44Gd2I9ndiZsLVh52ar9AAlmNiRs7eg9ByIOYtkMHGe0+6HBW9ithbSSKXcH8iFs7DuTvYZC31KKpFAuyhhE2v3kJkEK5YJZwytbtru7B8GGQjZCmhopmwkJgcRCu2o5jXwh2yWQWyxS3pH05teQwUpVK4Jkia49YA07l/ast8T3ihR7DfXvhuP/Mq2CATksarsRrBPuQQJx76Kp7vfGzh4F42V8zQe7YtxL+u2EkVoDZJ8+fej8VQi9vPRmg8BpCKXAN5OSkqpNVg0QR7VaPR3n05FLN6k9mcJnYLcK178ErEQRBIgTMtMNyG4Djaqv0XyJMtMBM4jrPCC8vb19KEHatWtXMHbs2LtOTk7lQoHGjRuXjBs37q6Hh0cRyvwZr+5/kW1s3GhXVVWlfxXv27fvhTlz5iybNm1aCuBVeEsqnzFjRmJoaOjS7t27X2fVXIgfdzfQtnnz5sPv3r2r/3/Rvn37WkdHR/8I1UNdXV1X4kdK+vfvPxsPNm3YsKE++JWWlmpbtNBH0C21QDY2NgOEk8LCwlY4340HhwM2DZfKcaxFJ+wsKip6OlfZoEGDwVIQD/Vrzc1Ciyb+/v4UGS9A0nx8fDxRHSdxGbzTaQ2q1qpVq3vnz58XGrYUbZIM0FVo0gOXyqBZ8p49ey6tW7fO8/Hjx7ZUrm3btgbZLe/p6Xnczs6ODI8bMWJEGiDTAfGAFjGo5nc4rh4zZswMaKYPKdSjXl5e8XLdfzQgIEBf6ODBg2qcv47qRcH4GuNlpRWOd+Bap8TERH0CNnz48Gv9+vVLkDNINXrtg8jIyEWootaYQaIHs2AKc5s1a7aVZS8GLuJ0//798M2bN4+NiYlxxztcLR90dHSsGDlyZHpwcHBU06ZNKWUuNRZGnGAjwTdu3BifkpLS7PLly05oJ65r164FMMZ0WH0UXIRG5GJz4pGajaad2RBOnXCZSYa0OrVAMueOEFc23tODuUyKxSBpQBS3hcbd3b396NGj+/v6+np16NDhVfRcNar40/fff5+ya9euk/n5+XeYlsoRomfPnv3j4+O3oJ0e1Ug2uMeDQ4cOfdmlS5deQlSVzgfoqzNkyJDXrl+/Hl9jYrt48eIh/GBHWRCq4HTq1KmtVLC4uDgZu48QVrKFhxGD7mC3DCZxjc5jY2M/o9HGAAQfGlBeXv6YCqEtKLd2weFYNM9jALNwTJ7e5OzZs1Hsx7JXrlzZ3QCk0+nmCb+el5d3Jzw8/ANKpnDqC6FBQLt27dp5CDGZQrnjx49/aACCe2yRNOx9wPsJvQBN3iorK8sXl7l58+bnUpDGwcGh1lQEQqyNt7d3GYUdeqXo1atXKQraissgWlbIDAyaZOzfZ/8+TMd5iEqluhMWFvZHmEIpjncDNAHttR6RUsuC31kDA4LanihUxOq+ivLGNWvWzAYjF4Hs3qJFi6bgWuvU1NStrBepR1satBH+0ERLJBXKyMi4AMP7Ag2bJbRHbm7unQMHDqzPzs7+ic5RNgw7lZxB0oErfumgKYOE5tHYNVSybAHmBlkB+8mXAnDtISALcdhI7LRiUUnmgowmEWj4akXvF1+g4Zs6hYmGRUIyhXLKRIzlUuJshEYOyvZDUBUHaTaCax/jcINcAiHORlpi6NmJHulrIhtZi06ZDViF3HAE43aINAahZAIWD0bl3wD7E55RGYBcXFy84f3vKkFo9IWVJ82aNSsVY34lNF8Ky25pAELW8Ta6VnZCSqvV0hB+ys/Pb/qZM2d2oRxlI+4Y194wAKFLe9IBDduBgYG3e/TooX/dwg+UzZw5U4chnNKatgjDoXAnDc07oikGGrQf1G1AB+3bt8/FABgJ1duvWrXqvUGDBl0HZBYgbSgtRBu6irIRZwONkDTRywqH0UL7zjvvvILBMQLD9+qhQ4cS5GVAvkIju4pMoQY/+osBCDFbh8arIkdEo89euHDhAgC+ZZpsFEP0bzbNmhUhG/nBADRgwIADqEbG0ymaqqrZqN5+xJ5NgBhMzmHcO4cU57gBqGXLlmkTJ07c0K1bt0dPp68qKjoCaLAOibJbZL00o5Oj5CKu6enpS5CIvo3hpjnito2kOsVBQUE/jxo16hP0zUY2q6OYRDijjQJv3boViDzJHdGyCaUz6Lnszp07X0GnbGRv5JXmZCPk/ZRD08wE2UoBez2/xhIJztxshGfZiBsbRSgePWKQEuk8tlI2Yo8M1xOJZz9kI52QWL2CqpYg6F9FHE/duXMnrX24K9c+4s0B7jEKxngQXV6ikI18gQy4h7FsRD116tQ3MzMzL5kK/uiEfTDgNrIgdKv7lStXYk2MHlmIkAV0jKHpYyRkDQxAyOqDULDMCITSGh/kRpMoa8GWsXr16l5SEA8H7AdHtJVrOGjxC+5NQui4mpyc3Ap7Ncb95sgHDGe+7t279x0biovhGovx8H6mSQZpQoYdFRW1VEgJcb/q9u3b6wyq9vDhwz1suD6PzL4nUhZnnG6AUBRshiQ+HJA80WBZmZWV9YkBKCcnZxErUI3R4Ru4Ak1wksO6b9q0abEYwjQtR0IWaABCKvc6bhYLBRGbd+NV9D1UJ4IyEmnjI9ymYecul43YoTfWiwtTBoJrRXK9iLYMUkwicPASChwxIxtZRm9TprKRxpDlaKocmWzkKnYTITbmZiNqNuNH89tjWSSk6aBk2FCWMe9/kf+7vnz5ilp1k55b8q+/moiI5TWiHpCemyVKD1sM44w8bDXI6mrJgercRnWGGbPsGpkB1CqDVP3GXeR3CLI4CsgZFzPGOvmaVRADkLWQWiApxKp4pACxDPQ8IIL3S728xlKHFexIVRevr3faFwZkdQIhE0ZeoJFWLh5ZBTOlidkwc6plFkwpibA4tPAW/FOh3tfqQRaBrHrRMZWNmDvyPheIrPdbmwO8wBmbNB5ZldLI2ZGq3td+RRBNz0NWWr2ShRaguLi4LFOr1R9UVVXdx6U5FoP8/Pym2dvbr8jLy3O2em1NUFDQ4cLCwoA6t9G2bdscpk6des3BwaGyTiC0yachISHX9+zZk4Qq3qtrxuYEmQWJO3v2bEzv3r2/qWui1R6y5Hl4f72vWTgjY0n78UoDZp2rplKpHCCd6gIiB+44evTod1NSUhZb21Yvd+jQYZROp9tZWVlZVlxcnKU03aFo2di8du/evVa88MQqEP58IZ0Itxakhkyj1R51AkkWDui1QzXvWw0SAWmVyjeWguq9vx70XCIkxjD6T3E4ZGlSUlK+1Rrt3buXFpPSmtFbyEimQdRWgRo0aPA2O6b/X6+DXAQs4Hm0EYXZw4CF1Qnk5uZWGhgY+CnaK9KqjM3W1rZ62LBhVydMmDDdw8PjqMWNlJubewL5UWZiYmIo/WPTmgRCiJBLIc2tBdTHo/+3tMaS1IZnRknLX23qpNLBgwddk5OT93p5edG/nFtLtTTbIOPi4uif4TXl5eUFBw4cWOfo6EgfWTS1GiRa7vnzmjVrKD9qXyeQaAuzBCS37OxnyAykf3utCiPck9U8tEIzEpASa15qaHkHLfloY860UL3314Pk4pG7u4ex+7QYhT60bA6Jh2yAlGZkpBu1bOlGn6HtF52P4Z587duVk6xpM1a1cSLIEchJkYazzG0jWuxOCTstfKMv6OhLMlquF8vuDzcH1I5BaKO1o/tEk3jC0sUcUyD69RvckwWDHIuStIDSHjKE3actwlgYoRXj/2HH9GYkfGlInyreEZ3/jXuyoFlWIy8RRBgAxJ+WCRD6cPdfxgzyI3ZMHwPu4Z6sgKaPLO+z6ze5J0usPzMVIYWPKZ0YuJr1lPB91ihImjmhlj5bfI118SlIHkRIRqeYAxFchNZiX+EMP6ScImq7WpuSi5SwTHYyc4u7rFEvWuS09TH79wz6nwADANCoQA3w0fcjAAAAAElFTkSuQmCC); + 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; +} diff --git a/lms/static/sass/course/layout/_timer.scss b/lms/static/sass/course/modules/_timer.scss similarity index 91% rename from lms/static/sass/course/layout/_timer.scss rename to lms/static/sass/course/modules/_timer.scss index dfff695c97..84bbb8c568 100644 --- a/lms/static/sass/course/layout/_timer.scss +++ b/lms/static/sass/course/modules/_timer.scss @@ -1,3 +1,6 @@ +// LMS -- modules -- student notes +// ==================== + div.timer-main { @extend %ui-depth2; position: fixed; diff --git a/lms/static/sass/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss new file mode 100644 index 0000000000..ef53e158dc --- /dev/null +++ b/lms/static/sass/elements/_navigation.scss @@ -0,0 +1,117 @@ +// 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); + } +} diff --git a/lms/static/sass/elements/_system-feedback.scss b/lms/static/sass/elements/_system-feedback.scss index 9a6061fa32..ec4e6412de 100644 --- a/lms/static/sass/elements/_system-feedback.scss +++ b/lms/static/sass/elements/_system-feedback.scss @@ -120,6 +120,10 @@ border-top: 3px solid $alert-color; } + &.error { + border-top: 3px solid $error-color; + } + &.warning { border-top: 3px solid $warning-color; } diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss index abdfe5f8bf..afdd3ceced 100644 --- a/lms/static/sass/elements/_typography.scss +++ b/lms/static/sass/elements/_typography.scss @@ -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; } // ==================== diff --git a/lms/templates/calculator/toggle_calculator.html b/lms/templates/calculator/toggle_calculator.html new file mode 100644 index 0000000000..7a244e07b0 --- /dev/null +++ b/lms/templates/calculator/toggle_calculator.html @@ -0,0 +1,139 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +
    + + +
    +
    +
    + + +
    +

    ${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}

    + + ${_("Hints")} + + +
    +
    + + + +
    +
    +
    diff --git a/lms/templates/chat/toggle_chat.html b/lms/templates/chat/toggle_chat.html new file mode 100644 index 0000000000..fcc72911e6 --- /dev/null +++ b/lms/templates/chat/toggle_chat.html @@ -0,0 +1,13 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +
    +
    + ${_('Open Chat')} + ${_('Close Chat')} +
    +
    + ## The Candy.js plugin wants to render in an element with #candy +
    +
    +
    diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index cdd5b53877..faccd8a14c 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -1,6 +1,7 @@ <%! 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()}
    -% if show_chat: -
    -
    - Open Chat - Close Chat -
    -
    - ## The Candy.js plugin wants to render in an element with #candy -
    -
    -
    -% endif + - ${_("Hints")} - - - - - - - - - - - -% endif <%include file="../modal/accessible_confirm.html" /> diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 073bdbd635..b26cc8dd5d 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -571,7 +571,7 @@ ${secondaryStateAction("close", "lock", _("Close"), _("Close"), _("Open"))} -${secondaryAction("edit", "pencil", _("Edit"))} +${secondaryAction("edit", "pencil-square-o", _("Edit"))} ${secondaryAction("delete", "remove", _("Delete"))} +% endfor + +<%block name="js_extra"> +% if notes: + +% endif + diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore new file mode 100644 index 0000000000..370f71ebc9 --- /dev/null +++ b/lms/templates/edxnotes/note-item.underscore @@ -0,0 +1,38 @@ +
    + <% if (message) { %> +
    +

    <%= message %> + <% if (show_link) { %> + <% if (is_expanded) { %> + <%- gettext('Less') %> + <% } else { %> + <%- gettext('More') %> + <% } %> + <% } %> +

    +
    + <% } %> + + <% if (text) { %> +
      +
    1. +

      <%- gettext("You commented...") %>

      +

      <%= text %>

      +
    2. +
    + <% } %> +
    + +
    +
    +

    <%- gettext("Noted in:") %>

    + <% if (unit.url) { %> + <%- unit.display_name %> + <% } else { %> + <%- unit.display_name %> + <% } %> + +

    <%- gettext("Last Edited:") %>

    + <%- updated %> +
    +
    diff --git a/lms/templates/edxnotes/tab-item.underscore b/lms/templates/edxnotes/tab-item.underscore new file mode 100644 index 0000000000..5a836d4ecd --- /dev/null +++ b/lms/templates/edxnotes/tab-item.underscore @@ -0,0 +1,13 @@ +<% var hasIcon = icon ? 1 : 0; %> + + + <% if (hasIcon) { %> <% } %><%- gettext(name) %> + + +<% if (is_closable) { %> + + + <%- gettext("Clear search results") %> + +<% } %> + diff --git a/lms/templates/edxnotes/toggle_notes.html b/lms/templates/edxnotes/toggle_notes.html new file mode 100644 index 0000000000..e50c6550e8 --- /dev/null +++ b/lms/templates/edxnotes/toggle_notes.html @@ -0,0 +1,27 @@ +<%! 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}) +%> +
    + + +
    + diff --git a/lms/templates/main.html b/lms/templates/main.html index eb355d33c4..d7b4f3ef83 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -163,7 +163,6 @@ - % if not disable_courseware_js: <%static:js group='application'/> <%static:js group='module-js'/> diff --git a/lms/urls.py b/lms/urls.py index 72c5102059..4af2de7571 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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 diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index b36f8d852d..0ea5a22f60 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -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", } }