From 8320989ef996431d818eccd7d4eb121d81b5e148 Mon Sep 17 00:00:00 2001 From: polesye Date: Thu, 23 Oct 2014 13:06:24 +0300 Subject: [PATCH 01/47] TNL-655: Add/Edit/Remove notes. --- cms/envs/test.py | 4 + common/djangoapps/edxnotes/__init__.py | 0 common/djangoapps/edxnotes/decorators.py | 45 +++ common/djangoapps/edxnotes/helpers.py | 51 +++ common/djangoapps/edxnotes/tests.py | 53 +++ common/djangoapps/terrain/stubs/edxnotes.py | 314 ++++++++++++++++++ common/djangoapps/terrain/stubs/http.py | 4 +- common/djangoapps/terrain/stubs/start.py | 2 + .../terrain/stubs/tests/test_edxnotes.py | 198 +++++++++++ common/lib/xmodule/xmodule/edxnotes_utils.py | 15 + common/lib/xmodule/xmodule/html_module.py | 21 +- .../css/vendor/edxnotes/annotator.min.css | 1 + .../js/vendor/edxnotes/annotator-full.min.js | 26 ++ common/templates/edxnotes_wrapper.html | 10 + common/test/acceptance/fixtures/__init__.py | 3 + common/test/acceptance/fixtures/edxnotes.py | 58 ++++ common/test/acceptance/pages/lms/edxnotes.py | 224 +++++++++++++ .../acceptance/tests/lms/test_lms_edxnotes.py | 207 ++++++++++++ .../tests/studio/test_studio_container.py | 3 +- .../tests/studio/test_studio_rerun.py | 3 +- lms/envs/bok_choy.py | 6 + lms/envs/common.py | 13 + lms/envs/devstack.py | 3 + lms/static/js/edxnotes/logger.js | 68 ++++ lms/static/js/edxnotes/notes.js | 96 ++++++ lms/static/js/edxnotes/shim.js | 39 +++ lms/static/js/fixtures/edxnotes/edxnotes.html | 3 + lms/static/js/spec/edxnotes/logger_spec.js | 55 +++ lms/static/js/spec/edxnotes/notes_spec.js | 35 ++ lms/static/js/spec/main.js | 12 +- lms/static/js_test.yml | 2 + lms/static/require-config.js | 22 ++ lms/static/sass/_developer.scss | 61 ++++ lms/templates/main.html | 1 - pavelib/utils/envs.py | 5 + 35 files changed, 1654 insertions(+), 9 deletions(-) create mode 100644 common/djangoapps/edxnotes/__init__.py create mode 100644 common/djangoapps/edxnotes/decorators.py create mode 100644 common/djangoapps/edxnotes/helpers.py create mode 100644 common/djangoapps/edxnotes/tests.py create mode 100644 common/djangoapps/terrain/stubs/edxnotes.py create mode 100644 common/djangoapps/terrain/stubs/tests/test_edxnotes.py create mode 100644 common/lib/xmodule/xmodule/edxnotes_utils.py create mode 100644 common/static/css/vendor/edxnotes/annotator.min.css create mode 100644 common/static/js/vendor/edxnotes/annotator-full.min.js create mode 100644 common/templates/edxnotes_wrapper.html create mode 100644 common/test/acceptance/fixtures/edxnotes.py create mode 100644 common/test/acceptance/pages/lms/edxnotes.py create mode 100644 common/test/acceptance/tests/lms/test_lms_edxnotes.py create mode 100644 lms/static/js/edxnotes/logger.js create mode 100644 lms/static/js/edxnotes/notes.js create mode 100644 lms/static/js/edxnotes/shim.js create mode 100644 lms/static/js/fixtures/edxnotes/edxnotes.html create mode 100644 lms/static/js/spec/edxnotes/logger_spec.js create mode 100644 lms/static/js/spec/edxnotes/notes_spec.js create mode 100644 lms/static/require-config.js diff --git a/cms/envs/test.py b/cms/envs/test.py index cf77fd011561..b59c961bbcdf 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -229,3 +229,7 @@ # Enable content libraries code for the tests FEATURES['ENABLE_CONTENT_LIBRARIES'] = True + +EDXNOTES_INTERFACE = { + 'url': 'http://localhost:8042/', +} diff --git a/common/djangoapps/edxnotes/__init__.py b/common/djangoapps/edxnotes/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/edxnotes/decorators.py b/common/djangoapps/edxnotes/decorators.py new file mode 100644 index 000000000000..844897ca9037 --- /dev/null +++ b/common/djangoapps/edxnotes/decorators.py @@ -0,0 +1,45 @@ +""" +Decorators related to edXNotes. +""" +from edxnotes.helpers import ( + get_prefix, + get_user_id, + generate_uid, + get_usage_id, + get_course_id, +) +from edxmako.shortcuts import render_to_string +from django.conf import settings + + +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) + + # Must be disabled in Studio or depend on the feature flag. + if is_studio or not settings.FEATURES.get('ENABLE_EDXNOTES'): + 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(), + 'params': { + # Use camelCase to name keys. + 'usageId': get_usage_id(), + 'courseId': get_course_id(), + 'prefix': get_prefix(), + 'user': get_user_id(), + 'debug': settings.DEBUG, + }, + }) + + cls.get_html = get_html + return cls diff --git a/common/djangoapps/edxnotes/helpers.py b/common/djangoapps/edxnotes/helpers.py new file mode 100644 index 000000000000..d561f3621947 --- /dev/null +++ b/common/djangoapps/edxnotes/helpers.py @@ -0,0 +1,51 @@ +""" +Helper methods related to EdxNotes. +""" +import datetime +from uuid import uuid4 +from django.conf import settings + + +def _now(): + """ + Returns current time in URC format. + """ + return datetime.datetime.utcnow().replace(microsecond=0) + + +def get_prefix(): + """ + Returns endpoint. + """ + url = settings.EDXNOTES_INTERFACE["url"] or "/" + if not url.endswith("/"): + url += "/" + return url + "api/v1" + + +def get_user_id(): + """ + Returns user id. + """ + return "edx_user" + + +def get_usage_id(): + """ + Returns usage id for the component. + """ + return None + + +def get_course_id(): + """ + Returns course id. + """ + return None + + +def generate_uid(): + """ + Generates unique id. + """ + return uuid4().int # pylint: disable=no-member diff --git a/common/djangoapps/edxnotes/tests.py b/common/djangoapps/edxnotes/tests.py new file mode 100644 index 000000000000..a92df9271f00 --- /dev/null +++ b/common/djangoapps/edxnotes/tests.py @@ -0,0 +1,53 @@ +""" +Tests for edX Notes app. +""" +import unittest +from mock import patch, Mock +from edxnotes.decorators import edxnotes + + +@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): + self.system = '' + + def get_html(self): + """ + Imitate get_html in module. + """ + return 'original_get_html' + + +class EdxNotesDecoratorTest(unittest.TestCase): + """ + Tests for edxnotes decorator. + """ + + def setUp(self): + self.problem = TestProblem() + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) + def test_edxnotes_enabled(self): + """ + Tests if get_html is wrapped when feature flag is on. + """ + self.assertIn('edx-notes-wrapper', self.problem.get_html()) + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': False}) + def test_edxnotes_disabled(self): + """ + Tests if 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 if get_html is not wrapped when problem is rendered in Studio. + """ + self.problem.system = Mock(is_author_mode=True) + self.assertEqual('original_get_html', self.problem.get_html()) diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py new file mode 100644 index 000000000000..b5082e6b25cd --- /dev/null +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -0,0 +1,314 @@ +""" +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["id"] = uuid4().hex + note["annotator_schema_version"] = "v1.0" + note["created"] = datetime.utcnow().isoformat() + note["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["id"] = uuid4().hex + note["annotator_schema_version"] = "v1.0" + note["created"] = datetime.utcnow().isoformat() + note["updated"] = 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) + if user is None or course_id is None: + self.respond(400, "Bad Request") + return + + notes = self.server.get_notes() + results = self.server.filter_by_user(notes, user) + results = self.server.filter_by_course_id(results, course_id) + if usage_id is not None: + results = self.server.filter_by_usage_id(results, usage_id) + self.respond(content={ + "total": len(results), + "rows": results, + }) + + 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=self.server.filter_by_user(notes, user)) + + 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. + """ + return deepcopy(self.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] diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py index 80d2a0e70b27..9d44af24f9eb 100644 --- a/common/djangoapps/terrain/stubs/http.py +++ b/common/djangoapps/terrain/stubs/http.py @@ -189,7 +189,9 @@ def send_response(self, status_code, content=None, headers=None): ) 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 0a60d425395f..95b8e54d9438 100644 --- a/common/djangoapps/terrain/stubs/start.py +++ b/common/djangoapps/terrain/stubs/start.py @@ -10,6 +10,7 @@ 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 @@ '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 000000000000..ad0d02316c0a --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py @@ -0,0 +1,198 @@ +""" +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. + """ + maxDiff = None + + 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"), params={ + "user": "user-without-notes", + "usage_id": "dummy-usage-id", + "course_id": "dummy-course-id", + }) + self.assertDictEqual({"total": 0, "rows": []}, 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 000000000000..e16f851973c5 --- /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 are exist. + """ + if "edxnotes" in sys.modules: + from edxnotes.decorators import edxnotes as notes + 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 7a81430918e5..acea5a3e55e5 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -16,6 +16,8 @@ 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 @@ def get_html(self): 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/static/css/vendor/edxnotes/annotator.min.css b/common/static/css/vendor/edxnotes/annotator.min.css new file mode 100644 index 000000000000..0584acfa2487 --- /dev/null +++ b/common/static/css/vendor/edxnotes/annotator.min.css @@ -0,0 +1 @@ +.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} \ No newline at end of file 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 000000000000..2f8597096b5c --- /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 f4a6d18398cb..f104adcad9bd 100644 --- a/common/test/acceptance/fixtures/__init__.py +++ b/common/test/acceptance/fixtures/__init__.py @@ -14,3 +14,6 @@ # 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 000000000000..ed4575534bd7 --- /dev/null +++ b/common/test/acceptance/fixtures/edxnotes.py @@ -0,0 +1,58 @@ +""" +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 = "/p[1]" + end = "/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_note(self, note): + self.notes.append(note) + 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 diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py new file mode 100644 index 000000000000..e1fcb96e41e4 --- /dev/null +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -0,0 +1,224 @@ +from .course_page import CoursePage +from selenium.webdriver.common.action_chains import ActionChains + +SELECTORS = { + 'wrapper': '.edx-notes-wrapper', + 'highlight': '.annotator-hl', + 'adder': '.annotator-adder', + 'textarea': '.annotator-item textarea', + 'button_cancel': '.annotator-cancel', + 'button_save': '.annotator-save', + 'button_edit': '.annotator-edit', + 'button_delete': '.annotator-delete', + 'viewer': '.annotator-viewer', + 'editor': '.annotator-editor', + 'popup': '.annotator-outer', +} + + +class EdxNotesUnitPage(CoursePage): + """ + Page for the Unit with EdxNotes. + """ + url_path = "courseware/" + _notes = [] + + def __init__(self, browser, course_id): + super(EdxNotesUnitPage, self).__init__(browser, course_id) + self.edxnotes_selector = ("body.courseware .edx-notes-wrapper") + + def is_browser_on_page(self): + return self.q(css=self.edxnotes_selector).present + + @property + def components(self): + """ + Returns a list of annotatable components. + """ + return [AnnotatedComponent(ac, self) for ac in self.q(css=SELECTORS['wrapper'])] + + @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 NotesMixin(object): + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular `AnnotatedComponent` context + """ + return '#{} {}'.format(self.id, selector) + + def find_css(self, selector): + return self.page.q(css=self._bounded_selector(selector)) + + +class AnnotatedComponent(NotesMixin): + """ + Helper class that works with annotated components. + """ + def __init__(self, element, page): + self.page = page + self.element = element + self.id = self.element.get_attribute('id') + + @property + def notes(self): + """ + Returns a list of notes for the component. + """ + return [EdxNote(hl, self.page, self.id) for hl in self.find_css(SELECTORS['highlight'])] + + 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.find_css(selector): + note = EdxNote(element, self.page, self.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.find_css(selector): + note = EdxNote(element, self.page, self.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.find_css(selector): + note = EdxNote(element, self.page, self.id) + note.show().remove() + + +class EdxNote(NotesMixin): + """ + Helper class that works with notes. + """ + def __init__(self, element, page, parent_id): + self.page = page + self.browser = page.browser + self.element = element + self.id = parent_id + + def wait_for_adder_visibility(self): + """ + Waiting for visibility of note adder button. + """ + self.page.wait_for_element_visibility( + self._bounded_selector(SELECTORS['adder']), 'Adder is visible.' + ) + + def wait_for_viewer_visibility(self): + """ + Waiting for visibility of note viewer. + """ + self.page.wait_for_element_visibility( + self._bounded_selector(SELECTORS['viewer']), 'Note Viewer is visible.' + ) + + def wait_for_editor_visibility(self): + """ + Waiting for visibility of note editor. + """ + self.page.wait_for_element_visibility( + self._bounded_selector(SELECTORS['editor']), 'Note Editor is visible.' + ) + + def wait_for_notes_invisibility(self, text="Notes are hidden"): + """ + Waiting for invisibility of all notes. + """ + selector = self._bounded_selector(SELECTORS['popup']) + self.page.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.find_css(SELECTORS['adder']).first.click() + self.wait_for_editor_visibility() + 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.find_css(SELECTORS['button_cancel']).first.click() + self.wait_for_notes_invisibility('Note is canceled.') + return self + + def save(self): + """ + Clicks save button. + """ + self.find_css(SELECTORS['button_save']).first.click() + self.wait_for_notes_invisibility('Note is saved.') + self.page.wait_for_ajax() + return self + + def remove(self): + """ + Clicks delete button. + """ + self.find_css(SELECTORS['button_delete']).first.click() + self.wait_for_notes_invisibility('Note is removed.') + self.page.wait_for_ajax() + return self + + def edit(self): + """ + Clicks edit button. + """ + self.find_css(SELECTORS['button_edit']).first.click() + self.wait_for_editor_visibility() + return self + + @property + def text(self): + """ + Returns text of the note. + """ + self.show().edit() + text = self.find_css(SELECTORS['textarea']).attrs('value')[0] + self.cancel() + return text + + @text.setter + def text(self, value): + """ + Sets text for the note. + """ + self.find_css(SELECTORS['textarea']).first.fill(value) 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 000000000000..564c8bf1d8b9 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -0,0 +1,207 @@ +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 +from ...fixtures.edxnotes import EdxNotesFixture, Note, Range + + +class EdxNotesTest(UniqueCourseTest): + """ + Tests for annotation inside HTML components in LMS. + """ + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(EdxNotesTest, self).setUp() + self.courseware_page = CoursewarePage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.note_page = EdxNotesUnitPage(self.browser, self.course_id) + + self.edxnotes_fix = EdxNotesFixture() + self.course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + self.selector = "annotate-id" + self.course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('vertical', 'Test Vertical').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( + 'html', + 'Test HTML 3', + data="""

Annotate this text!

""".format(self.selector) + ), + ), + )).install() + + # Auto-auth register for the course + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + def _add_notes(self): + xblocks = self.course_fix.get_nested_xblocks(category="html") + for index, xblock in enumerate(xblocks): + self.edxnotes_fix.create_note( + Note( + usage_id=xblock.locator, + user="edx_user", + course_id=self.course_fix._course_key, + ranges=[Range(startOffset=index, endOffset=index + 5)] + ) + ) + self.edxnotes_fix.install() + + def create_notes(self, components, offset=0): + self.assertGreater(len(components), 0) + index = offset + for component in components: + for note in component.create_note(".annotate-id"): + 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: + 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: + 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, components, offset=0): + index = offset + for component in components: + actual = [note.text for note in component.notes] + expected = ['TEST TEXT {}'.format(i + index) for i in xrange(len(actual))] + index += len(actual) + self.assertItemsEqual(expected, actual) + + def test_can_create_notes(self): + """ + Scenario: User can create notes. + Given I have a course with 3 annotatatble components + And I open the unit with 2 annotatatble 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 annotatatble 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_page.visit() + + components = self.note_page.components + self.create_notes(components) + self.assert_text_in_notes(components) + offset = len(self.note_page.notes) + + self.course_nav.go_to_sequential_position(2) + components = self.note_page.components + self.create_notes(components, offset) + self.assert_text_in_notes(components, offset) + + components = self.note_page.refresh() + self.assert_text_in_notes(components, offset) + + self.course_nav.go_to_sequential_position(1) + components = self.note_page.components + self.assert_text_in_notes(components) + + 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 annotatatble 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_page.visit() + + components = self.note_page.components + self.edit_notes(components) + self.assert_text_in_notes(components) + offset = len(self.note_page.notes) + + self.course_nav.go_to_sequential_position(2) + components = self.note_page.components + self.edit_notes(components, offset) + self.assert_text_in_notes(components, offset) + + components = self.note_page.refresh() + self.assert_text_in_notes(components, offset) + + self.course_nav.go_to_sequential_position(1) + components = self.note_page.components + self.assert_text_in_notes(components) + + 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 annotatatble 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_page.visit() + + components = self.note_page.components + self.remove_notes(components) + self.assert_notes_are_removed(components) + + self.course_nav.go_to_sequential_position(2) + components = self.note_page.components + self.remove_notes(components) + self.assert_notes_are_removed(components) + + components = self.note_page.refresh() + self.assert_notes_are_removed(components) + + self.course_nav.go_to_sequential_position(1) + components = self.note_page.components + self.assert_notes_are_removed(components) diff --git a/common/test/acceptance/tests/studio/test_studio_container.py b/common/test/acceptance/tests/studio/test_studio_container.py index 79a2fc8471f9..f2c485b75e19 100644 --- a/common/test/acceptance/tests/studio/test_studio_container.py +++ b/common/test/acceptance/tests/studio/test_studio_container.py @@ -443,7 +443,8 @@ def test_view_live_changes(self): add_discussion(unit) self._view_published_version(unit) self._verify_components_visible(['html']) - self.assertEqual(self.html_content, self.courseware.xblock_component_html_content(0)) + # We cannot use `assertEqual`, because EdxNotes adds its own DOM elements. + self.assertIn(self.html_content, self.courseware.xblock_component_html_content(0)) def test_view_live_after_publish(self): """ diff --git a/common/test/acceptance/tests/studio/test_studio_rerun.py b/common/test/acceptance/tests/studio/test_studio_rerun.py index a193fd46fc2b..476c7074ce69 100644 --- a/common/test/acceptance/tests/studio/test_studio_rerun.py +++ b/common/test/acceptance/tests/studio/test_studio_rerun.py @@ -101,4 +101,5 @@ def finished_processing(): courseware = CoursewarePage(self.browser, self.course_id) courseware.wait_for_page() self.assertEqual(courseware.num_xblock_components, 1) - self.assertEqual(courseware.xblock_component_html_content(), self.COMPONENT_CONTENT) + # We cannot use `assertEqual`, because EdxNotes adds its own DOM elements. + self.assertIn(self.COMPONENT_CONTENT, courseware.xblock_component_html_content()) diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index af3b992808f8..feb3546aa85c 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -66,6 +66,12 @@ # 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/', +} +FEATURES['ENABLE_EDXNOTES'] = True + # Enable django-pipeline and staticfiles STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath() diff --git a/lms/envs/common.py b/lms/envs/common.py index 8623017d421a..187f90c02872 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -311,6 +311,8 @@ # Show the mobile app links in the footer 'ENABLE_FOOTER_MOBILE_APP_LINKS': False, + + 'ENABLE_EDXNOTES': False, } # Ignore static asset files on import which match this pattern @@ -911,6 +913,14 @@ # 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:8042/', +} + ################################# Jasmine ################################## JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' @@ -1174,6 +1184,7 @@ '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', }, @@ -1520,6 +1531,8 @@ 'django_comment_common', 'notes', + 'edxnotes', + # Splash screen 'splash', diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 255d180e8b4d..aba19591575a 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -95,6 +95,9 @@ FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True +################################ edX Student Notes ################################ +FEATURES['ENABLE_EDXNOTES'] = True + ##################################################################### # See if the developer has any local overrides. try: diff --git a/lms/static/js/edxnotes/logger.js b/lms/static/js/edxnotes/logger.js new file mode 100644 index 000000000000..b4ffe871c221 --- /dev/null +++ b/lms/static/js/edxnotes/logger.js @@ -0,0 +1,68 @@ +;(function (define) { + define([], function () { + 'use strict'; + /** + * Logger constructor. + * @constructor + * @param {String} id Id of the logger. + * @param {Boolean|Number} mode Outputs messages to the Web Console if true. + */ + var Logger = function (id, mode) { + this.id = id; + this._history = []; + // 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. + */ + Logger.prototype._log = function (logType, args) { + this.updateHistory.apply(this, arguments); + // Adds ID at the first place + Array.prototype.unshift.call(args, this.id); + if (this.logLevel && 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. + */ + Logger.prototype.log = function () { + this._log('log', arguments); + }; + + /** + * Outputs an error message to the Web Console and store it in the history. + */ + Logger.prototype.error = function () { + this._log('error', arguments); + }; + + /** + * Adds information to the history. + */ + Logger.prototype.updateHistory = function () { + this._history.push(arguments); + }; + + /** + * Returns the history for the logger. + * @return {Array} + */ + Logger.prototype.getHistory = function () { + return this._history; + }; + + return Logger; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/notes.js b/lms/static/js/edxnotes/notes.js new file mode 100644 index 000000000000..06f5380118e9 --- /dev/null +++ b/lms/static/js/edxnotes/notes.js @@ -0,0 +1,96 @@ +;(function (define, $, _, undefined) { + 'use strict'; + define([ + 'annotator', 'js/edxnotes/logger', 'js/edxnotes/shim' + ], function (Annotator, Logger) { + var plugins = ['Store'], + getUsageId, getCourseId, getOptions, setupPlugins, getAnnotator; + + /** + * Returns Usage id for the component. + * @param {jQuery Element} The container element. + * @return {String} Usage id. + **/ + getUsageId = function (element) { + return element.closest('[data-usage-id]').data('usage-id'); + }; + + /** + * Returns course id for the component. + * @param {jQuery Element} The container element. + * @return {String} Course id. + **/ + getCourseId = function (element) { + return element.closest('[data-course-id]').data('course-id'); + }; + + /** + * Returns options for the annotator. + * @param {jQuery Element} The container element. + * @param {String} params.prefix 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. + * @return {Object} Options. + **/ + getOptions = function (element, params) { + var usageId = params.usageId || getUsageId(element), + courseId = params.courseId || getCourseId(element), + defaultParams = { + user: params.user, + usage_id: usageId, + course_id: courseId + }; + + return { + store: { + prefix: params.prefix, + annotationData: defaultParams, + loadFromSearch: defaultParams + } + }; + }; + + /** + * 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.prefix 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. + * @return {Object} An instance of Annotator.js. + **/ + getAnnotator = function (element, params) { + var el = $(element), + options = getOptions(el, params), + annotator = el.annotator(options).data('annotator'), + logger = new Logger(element.id, params.debug); + + setupPlugins(annotator, plugins, options); + annotator.logger = logger; + logger.log({ + 'element': element, + 'options': options, + 'annotator': annotator + }); + return annotator; + }; + + return { + factory: getAnnotator + }; + }); +}).call(this, define || RequireJS.define, jQuery, _); diff --git a/lms/static/js/edxnotes/shim.js b/lms/static/js/edxnotes/shim.js new file mode 100644 index 000000000000..fc1ab91aad69 --- /dev/null +++ b/lms/static/js/edxnotes/shim.js @@ -0,0 +1,39 @@ +;(function (define, $, _, undefined) { + 'use strict'; + define(['annotator'], function (Annotator) { + var _t = Annotator._t; + + /** + * 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.Viewer.html.item template to add an i18n for the + * buttons. + **/ + Annotator.Viewer.prototype.html.item = [ + '
  • ', + '', + '', + _t('View as webpage'), + '', + '', + '', + '', + '
  • ' + ].join(''); + }); +}).call(this, define || RequireJS.define, jQuery, _); diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html new file mode 100644 index 000000000000..3e73c2a1d319 --- /dev/null +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -0,0 +1,3 @@ +
    + Annotate it! +
    diff --git a/lms/static/js/spec/edxnotes/logger_spec.js b/lms/static/js/spec/edxnotes/logger_spec.js new file mode 100644 index 000000000000..9dc7760f3801 --- /dev/null +++ b/lms/static/js/spec/edxnotes/logger_spec.js @@ -0,0 +1,55 @@ +define(['js/edxnotes/logger'], + function(Logger) { + 'use strict'; + + describe('Test logger', function() { + var log, logs, logger; + + beforeEach(function() { + logger = new Logger('id', 0); // 0 is silent mode + }); + + it('Tests if the logger keeps a correct history of logs', function() { + logger.log('A log type', 'A first log'); + logger.log('A log type', 'A second log'); + + 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('Tests if the logger keeps a correct history of errors', function() { + logger.error('An error type', 'A first error'); + logger.error('An error type', 'A second error'); + + 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'); + }); + }); + } +); diff --git a/lms/static/js/spec/edxnotes/notes_spec.js b/lms/static/js/spec/edxnotes/notes_spec.js new file mode 100644 index 000000000000..511d1509fb41 --- /dev/null +++ b/lms/static/js/spec/edxnotes/notes_spec.js @@ -0,0 +1,35 @@ +define(['jquery', 'js/edxnotes/notes', 'jasmine-jquery'], + function($, Notes) { + 'use strict'; + + describe('Test notes', function() { + var wrapper; + + beforeEach(function() { + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + wrapper = $('div#edx-notes-wrapper-123'); + }); + + it('Tests that annotator is initialized with options correctly', function() { + var annotator, internalOptions; + + internalOptions = { + user: 'a user', + usage_id : 'an usage', + course_id: 'a course' + }; + + annotator = Notes.factory(wrapper[0], { + prefix: 'a prefix', + user: 'a user', + usageId : 'an usage', + courseId: 'a course' + }); + + expect(annotator.options.store.prefix).toBe('a prefix'); + expect(annotator.options.store.annotationData).toEqual(internalOptions); + expect(annotator.options.store.loadFromSearch).toEqual(internalOptions); + }); + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 49de0cb6c801..48cf87f67aba 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -78,6 +78,9 @@ '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' + + // edxnotes + 'annotator': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' }, shim: { 'gettext': { @@ -488,6 +491,11 @@ 'js/verify_student/views/enrollment_confirmation_step_view' ] }, + // Student Notes + 'annotator': { + exports: 'Annotator', + deps: ['jquery'] + } } }); @@ -514,7 +522,9 @@ '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/logger_spec.js', + 'lms/include/js/spec/edxnotes/notes_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index a1635660b18f..fa612921434c 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -55,6 +55,7 @@ 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 # Paths to source JavaScript files src_paths: @@ -81,6 +82,7 @@ fixture_paths: - templates/student_profile - templates/verify_student - templates/file-upload.underscore + - js/fixtures/edxnotes requirejs: paths: diff --git a/lms/static/require-config.js b/lms/static/require-config.js new file mode 100644 index 000000000000..bbb4a44d3f65 --- /dev/null +++ b/lms/static/require-config.js @@ -0,0 +1,22 @@ +;(function (require) { + require.config({ + // NOTE: baseUrl has been previously set in lms/static/templates/main.html + waitSeconds: 60, + paths: { + 'annotator_1.2.9': 'js/vendor/edxnotes/annotator-full.min' + }, + shim: { + 'annotator_1.2.9': { + exports: 'Annotator' + } + }, + map: { + 'js/edxnotes/notes': { + 'annotator': 'annotator_1.2.9' + }, + 'js/edxnotes/shim': { + 'annotator': 'annotator_1.2.9' + } + } + }); +}).call(this, require || RequireJS.require); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 25da3cbbf0f8..ae87a57e0217 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -8,3 +8,64 @@ // } // -------------------- + +// 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/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') !important; + + &.annotator-edit { + background-position: 0 -60px !important; + } + + &.annotator-delete { + background-position: 0 -75px !important; + } + + &.annotator-link { + background-position: 0 -270px !important; + } + } + } +} diff --git a/lms/templates/main.html b/lms/templates/main.html index eb355d33c473..d7b4f3ef8379 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/pavelib/utils/envs.py b/pavelib/utils/envs.py index b36f8d852d74..0ea5a22f6094 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", } } From 1d24613c858fc370748d43d3e795a63c8b551040 Mon Sep 17 00:00:00 2001 From: polesye Date: Fri, 7 Nov 2014 16:53:22 +0200 Subject: [PATCH 02/47] TNL-797: Add Notes page. --- .../tests/test_course_settings.py | 90 +++++ cms/djangoapps/contentstore/utils.py | 3 +- cms/djangoapps/contentstore/views/course.py | 89 ++++- cms/djangoapps/contentstore/views/tabs.py | 1 + .../models/settings/course_metadata.py | 4 + cms/envs/common.py | 3 + cms/envs/devstack.py | 2 + cms/envs/test.py | 1 + common/djangoapps/edxnotes/helpers.py | 51 --- common/djangoapps/edxnotes/tests.py | 53 --- common/djangoapps/terrain/stubs/edxnotes.py | 26 +- .../terrain/stubs/tests/test_edxnotes.py | 2 - common/lib/xmodule/xmodule/edxnotes_utils.py | 2 +- .../xmodule/modulestore/inheritance.py | 6 + common/lib/xmodule/xmodule/tabs.py | 25 ++ common/lib/xmodule/xmodule/tests/test_tabs.py | 37 ++ common/templates/edxnotes_wrapper.html | 10 +- common/test/acceptance/fixtures/edxnotes.py | 28 +- .../test/acceptance/pages/lms/courseware.py | 6 +- common/test/acceptance/pages/lms/edxnotes.py | 228 +++++++---- .../acceptance/tests/lms/test_lms_edxnotes.py | 274 ++++++++++--- .../tests/studio/test_studio_container.py | 3 +- .../tests/studio/test_studio_rerun.py | 3 +- common/test/db_fixtures/edx-notes_client.json | 15 + .../djangoapps/edxnotes/__init__.py | 0 .../djangoapps/edxnotes/decorators.py | 21 +- lms/djangoapps/edxnotes/helpers.py | 172 +++++++++ lms/djangoapps/edxnotes/tests.py | 359 ++++++++++++++++++ lms/djangoapps/edxnotes/views.py | 40 ++ lms/envs/common.py | 2 +- lms/envs/test.py | 3 + lms/static/js/edxnotes/collections/notes.js | 12 + lms/static/js/edxnotes/models/note.js | 24 ++ lms/static/js/edxnotes/{ => utils}/logger.js | 0 lms/static/js/edxnotes/views/note_item.js | 28 ++ lms/static/js/edxnotes/{ => views}/notes.js | 60 ++- lms/static/js/edxnotes/views/notes_page.js | 39 ++ lms/static/js/edxnotes/views/page_factory.js | 31 ++ .../js/edxnotes/views/recent_activity_view.js | 33 ++ lms/static/js/edxnotes/{ => views}/shim.js | 0 lms/static/js/spec/edxnotes/notes_spec.js | 4 +- .../spec/edxnotes/{ => utils}/logger_spec.js | 2 +- .../js/spec/edxnotes/views/notes_page_spec.js | 65 ++++ lms/static/js/spec/main.js | 7 +- lms/static/js_test.yml | 3 + lms/static/require-config-lms.js | 75 +++- lms/static/require-config.js | 22 -- lms/static/sass/_developer.scss | 39 ++ lms/static/sass/course-rtl.scss.mako | 1 + lms/static/sass/course.scss.mako | 1 + lms/static/sass/course/_edxnotes.scss | 78 ++++ lms/templates/edxnotes.html | 55 +++ lms/templates/edxnotes/note-item.underscore | 24 ++ lms/urls.py | 3 + 54 files changed, 1819 insertions(+), 346 deletions(-) delete mode 100644 common/djangoapps/edxnotes/helpers.py delete mode 100644 common/djangoapps/edxnotes/tests.py create mode 100644 common/test/db_fixtures/edx-notes_client.json rename {common => lms}/djangoapps/edxnotes/__init__.py (100%) rename {common => lms}/djangoapps/edxnotes/decorators.py (59%) create mode 100644 lms/djangoapps/edxnotes/helpers.py create mode 100644 lms/djangoapps/edxnotes/tests.py create mode 100644 lms/djangoapps/edxnotes/views.py create mode 100644 lms/static/js/edxnotes/collections/notes.js create mode 100644 lms/static/js/edxnotes/models/note.js rename lms/static/js/edxnotes/{ => utils}/logger.js (100%) create mode 100644 lms/static/js/edxnotes/views/note_item.js rename lms/static/js/edxnotes/{ => views}/notes.js (65%) create mode 100644 lms/static/js/edxnotes/views/notes_page.js create mode 100644 lms/static/js/edxnotes/views/page_factory.js create mode 100644 lms/static/js/edxnotes/views/recent_activity_view.js rename lms/static/js/edxnotes/{ => views}/shim.js (100%) rename lms/static/js/spec/edxnotes/{ => utils}/logger_spec.js (98%) create mode 100644 lms/static/js/spec/edxnotes/views/notes_page_spec.js delete mode 100644 lms/static/require-config.js create mode 100644 lms/static/sass/course/_edxnotes.scss create mode 100644 lms/templates/edxnotes.html create mode 100644 lms/templates/edxnotes/note-item.underscore diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 5062450cf561..0a613a93d722 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -552,6 +552,80 @@ def test_update_from_json_filtered_off(self): ) 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 on, 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,22 @@ def test_advanced_components_munge_tabs(self): course = modulestore().get_course(self.course.id) self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs) + 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 e6c6d458cc7a..07a8a8cac1c5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -30,7 +30,8 @@ # 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 f40e86f08204..38a75e868561 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -866,6 +866,37 @@ def grading_handler(request, course_key_string, grader_index=None): return JsonResponse() +# pylint: disable=invalid-name +def _add_tab(request, tab_type, course_module): + """ + Adds tab to the course. + """ + # Add tab to the course if needed + changed, new_tabs = add_extra_panel_tab(tab_type, course_module) + # If a tab has been added to the course, then send the + # metadata along to CourseMetadata.update_from_json + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs': {'value': new_tabs}}) + # Indicate that tabs should not be filtered out of + # the metadata + return True + return False + + +# pylint: disable=invalid-name +def _remove_tab(request, tab_type, course_module): + """ + Removes the tab from the course. + """ + changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs': {'value': new_tabs}}) + return True + return False + + # pylint: disable=invalid-name def _config_course_advanced_components(request, course_module): """ @@ -880,6 +911,7 @@ def _config_course_advanced_components(request, course_module): """ # 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, @@ -890,22 +922,13 @@ def _config_course_advanced_components(request, course_module): component_types = tab_component_map.get(tab_type) found_ac_type = False for ac_type in component_types: - # Check if the user has incorrectly failed to put the value in an iterable. new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value'] if hasattr(new_advanced_component_list, '__iter__'): if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES: - - # Add tab to the course if needed - changed, new_tabs = add_extra_panel_tab(tab_type, course_module) - # If a tab has been added to the course, then send the - # metadata along to CourseMetadata.update_from_json - if changed: - course_module.tabs = new_tabs - request.json.update({'tabs': {'value': new_tabs}}) - # Indicate that tabs should not be filtered out of - # the metadata - filter_tabs = False # Set this flag to avoid the tab removal code below. + if _add_tab(request, tab_type, course_module): + # Set this flag to avoid the tab removal code below. + filter_tabs = False found_ac_type = True # break else: # If not iterable, return immediately and let validation handle. @@ -914,10 +937,7 @@ def _config_course_advanced_components(request, course_module): # 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}}) + if _remove_tab(request, tab_type, course_module): # Indicate that tabs should *not* be filtered out of # the metadata filter_tabs = False @@ -925,6 +945,42 @@ def _config_course_advanced_components(request, course_module): return filter_tabs + # pylint: disable=invalid-name +def _config_course_settings(request, course_module, filter_tabs=True): + """ + Check to see if the user enabled some advanced settings (boolean). + This is a hack that does the following : + 1) adds/removes the edx notes panel tab to a course automatically if + the user has indicated that they want the notes module enabled in + their course + """ + tab_component_map = { + 'edxnotes': ['edxnotes'] + } + # Check to see if the user instantiated any notes or open ended components + for tab_type in tab_component_map.keys(): + if tab_type in request.json: + component_types = tab_component_map.get(tab_type) + found_ac_type = False + for ac_type in component_types: + field_value = request.json[ac_type]['value'] + if field_value is True: + if _add_tab(request, ac_type, course_module): + # Set this flag to avoid the tab removal code below. + filter_tabs = False + found_ac_type = True # break + + # 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 + if _remove_tab(request, ac_type, course_module): + # Indicate that tabs should *not* be filtered out of + # the metadata + filter_tabs = False + + return filter_tabs + + @login_required @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT")) @@ -956,6 +1012,7 @@ def advanced_settings_handler(request, course_key_string): try: # Whether or not to filter the tabs key out of the settings metadata filter_tabs = _config_course_advanced_components(request, course_module) + filter_tabs = _config_course_settings(request, course_module, filter_tabs) # validate data formats and update is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json( diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 131be1d946b0..ce3aca9368fe 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -61,6 +61,7 @@ def tabs_handler(request, course_key_string): # present in the same order they are displayed in LMS tabs_to_render = [] + for tab in CourseTabList.iterate_displayable_cms( course_item, settings, diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 052ecfeb5700..78e45ef5f00a 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -47,6 +47,10 @@ def filtered_list(cls): if not settings.FEATURES.get('ENABLE_EXPORT_GIT'): filtered_list.append('giturl') + # Do not show edxnotes if feature is not enabled. + 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 19d6b55b2daa..660d26fdae7c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -116,6 +116,9 @@ # 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 + 'ENABLE_EDXNOTES': True, } ENABLE_JASMINE = False diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 50fc0a1b37e2..e2cee0384438 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -85,3 +85,5 @@ ##################################################################### # Lastly, run any migrations, if needed. MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE) + +FEATURES['ENABLE_EDXNOTES'] = True diff --git a/cms/envs/test.py b/cms/envs/test.py index b59c961bbcdf..cfa520c209cf 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -230,6 +230,7 @@ # Enable content libraries code for the tests FEATURES['ENABLE_CONTENT_LIBRARIES'] = True +FEATURES['ENABLE_EDXNOTES'] = True EDXNOTES_INTERFACE = { 'url': 'http://localhost:8042/', } diff --git a/common/djangoapps/edxnotes/helpers.py b/common/djangoapps/edxnotes/helpers.py deleted file mode 100644 index d561f3621947..000000000000 --- a/common/djangoapps/edxnotes/helpers.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Helper methods related to EdxNotes. -""" -import datetime -from uuid import uuid4 -from django.conf import settings - - -def _now(): - """ - Returns current time in URC format. - """ - return datetime.datetime.utcnow().replace(microsecond=0) - - -def get_prefix(): - """ - Returns endpoint. - """ - url = settings.EDXNOTES_INTERFACE["url"] or "/" - if not url.endswith("/"): - url += "/" - return url + "api/v1" - - -def get_user_id(): - """ - Returns user id. - """ - return "edx_user" - - -def get_usage_id(): - """ - Returns usage id for the component. - """ - return None - - -def get_course_id(): - """ - Returns course id. - """ - return None - - -def generate_uid(): - """ - Generates unique id. - """ - return uuid4().int # pylint: disable=no-member diff --git a/common/djangoapps/edxnotes/tests.py b/common/djangoapps/edxnotes/tests.py deleted file mode 100644 index a92df9271f00..000000000000 --- a/common/djangoapps/edxnotes/tests.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Tests for edX Notes app. -""" -import unittest -from mock import patch, Mock -from edxnotes.decorators import edxnotes - - -@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): - self.system = '' - - def get_html(self): - """ - Imitate get_html in module. - """ - return 'original_get_html' - - -class EdxNotesDecoratorTest(unittest.TestCase): - """ - Tests for edxnotes decorator. - """ - - def setUp(self): - self.problem = TestProblem() - - @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) - def test_edxnotes_enabled(self): - """ - Tests if get_html is wrapped when feature flag is on. - """ - self.assertIn('edx-notes-wrapper', self.problem.get_html()) - - @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': False}) - def test_edxnotes_disabled(self): - """ - Tests if 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 if get_html is not wrapped when problem is rendered in Studio. - """ - self.problem.system = Mock(is_author_mode=True) - self.assertEqual('original_get_html', self.problem.get_html()) diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py index b5082e6b25cd..38b43ff45775 100644 --- a/common/djangoapps/terrain/stubs/edxnotes.py +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -127,10 +127,12 @@ def _create(self): Create a note, assign id, annotator_schema_version, created and updated dates. """ note = json.loads(self.request_content) - note["id"] = uuid4().hex - note["annotator_schema_version"] = "v1.0" - note["created"] = datetime.utcnow().isoformat() - note["updated"] = datetime.utcnow().isoformat() + 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) @@ -149,10 +151,12 @@ def _create_notes(self): return for note in notes: - note["id"] = uuid4().hex - note["annotator_schema_version"] = "v1.0" - note["created"] = datetime.utcnow().isoformat() - note["updated"] = datetime.utcnow().isoformat() + 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) @@ -194,13 +198,15 @@ def _search(self): 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) - if user is None or course_id is None: + + if user is None: self.respond(400, "Bad Request") return notes = self.server.get_notes() results = self.server.filter_by_user(notes, user) - results = self.server.filter_by_course_id(results, course_id) + if course_id is not None: + results = self.server.filter_by_course_id(results, course_id) if usage_id is not None: results = self.server.filter_by_usage_id(results, usage_id) self.respond(content={ diff --git a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py index ad0d02316c0a..10055045a13c 100644 --- a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py +++ b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py @@ -13,8 +13,6 @@ class StubEdxNotesServiceTest(unittest.TestCase): """ Test cases for the stub EdxNotes service. """ - maxDiff = None - def setUp(self): """ Start the stub server. diff --git a/common/lib/xmodule/xmodule/edxnotes_utils.py b/common/lib/xmodule/xmodule/edxnotes_utils.py index e16f851973c5..a041f610212b 100644 --- a/common/lib/xmodule/xmodule/edxnotes_utils.py +++ b/common/lib/xmodule/xmodule/edxnotes_utils.py @@ -9,7 +9,7 @@ def edxnotes(cls): Conditional decorator that loads edxnotes only when they are exist. """ if "edxnotes" in sys.modules: - from edxnotes.decorators import edxnotes as notes + from edxnotes.decorators import edxnotes as notes # pylint: disable=import-error return notes(cls) else: return cls diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 3ec2f96dbd14..82aa07fe8b5c 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -172,6 +172,12 @@ class InheritanceMixin(XBlockMixin): scope=Scope.settings, default=default_reset_button ) + edxnotes = Boolean( + display_name=_("Enable Notes"), + help=_("Enter true or false. If true, you can use the Notes for HTML components."), + default=False, + scope=Scope.settings + ) def compute_inherited_metadata(descriptor): diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index b5d64a6a6eb9..9c853f9d8122 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -69,6 +69,7 @@ def can_display(self, course, settings, is_user_authenticated, is_user_staff, is 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 @@ def from_json(tab_dict): 'staff_grading': StaffGradingTab, 'open_ended': OpenEndedGradingTab, 'notes': NotesTab, + 'edxnotes': EdxNotesTab, 'syllabus': SyllabusTab, 'instructor': InstructorTab, # not persisted } @@ -694,6 +696,27 @@ def validate(cls, tab_dict, raise_error=True): 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. @@ -807,6 +830,7 @@ def iterate_displayable( yield item else: yield tab + instructor_tab = InstructorTab() if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): yield instructor_tab @@ -860,6 +884,7 @@ def validate_tabs(cls, tabs): TextbookTabs.type, PDFTextbookTabs.type, HtmlTextbookTabs.type, + EdxNotesTab.type, ]: cls._validate_num_tabs_of_type(tabs, tab_type, 1) diff --git a/common/lib/xmodule/xmodule/tests/test_tabs.py b/common/lib/xmodule/xmodule/tests/test_tabs.py index 7e1ed5964bb7..3a2d7384bfc1 100644 --- a/common/lib/xmodule/xmodule/tests/test_tabs.py +++ b/common/lib/xmodule/xmodule/tests/test_tabs.py @@ -412,6 +412,40 @@ def test_instructor_tab(self): 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): + """ + Test that check if edxnotes tab can be enabled correctly. + """ + 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): + """ + Test that check if edxnotes tab doewn't work 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 @@ def setUp(self): tabs.TextbookTabs.type, tabs.PDFTextbookTabs.type, tabs.HtmlTextbookTabs.type, + tabs.EdxNotesTab.type, ] for unique_tab_type in unique_tab_types: @@ -505,6 +540,7 @@ def setUp(self): {'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 @@ def test_iterate_displayable(self): 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/templates/edxnotes_wrapper.html b/common/templates/edxnotes_wrapper.html index cb1d1edde5e0..52d5f4255eda 100644 --- a/common/templates/edxnotes_wrapper.html +++ b/common/templates/edxnotes_wrapper.html @@ -1,8 +1,14 @@ <%! import json %> -
    ${content}
    +<% + if user: + params.update({'user': user.username}) +%> +
    +
    ${content}
    +
    +% 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 000000000000..cd3ee4179b29 --- /dev/null +++ b/lms/templates/edxnotes/note-item.underscore @@ -0,0 +1,24 @@ +
    +<% if (quote) { %> +
    <%- quote %>
    +<% } %> +<% if (text) { %> +
    <%- text %>
    +<% } %> +
    +
    +
    + <% if (text && quote) { %> +
    <%- gettext("Highlighted & Noted in:") %>
    + <% } else if (text) { %> +
    <%- gettext("Highlighted in:") %>
    + <% } else if (quote) { %> +
    <%- gettext("Noted in:") %>
    + <% } %> +
    <%- unit.display_name %>
    + <% if (updated) { %> +
    <%- gettext("Last Edited:") %>
    +
    <%- updated %>
    + <% } %> +
    +
    diff --git a/lms/urls.py b/lms/urls.py index 72c510205901..b09209a55bb9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -380,6 +380,9 @@ # 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), 'edxnotes.views.edxnotes', name='edxnotes'), ) # allow course staff to change to student view of courseware From d21870eefbc63770608d5b7c5c5be6c0c605b907 Mon Sep 17 00:00:00 2001 From: polesye Date: Wed, 5 Nov 2014 15:55:47 +0200 Subject: [PATCH 03/47] TNL-660: Toggle single note visibility. --- common/test/acceptance/pages/lms/edxnotes.py | 44 ++++++- common/test/acceptance/tests/helpers.py | 3 + .../acceptance/tests/lms/test_lms_edxnotes.py | 98 ++++++++++++++-- lms/static/js/edxnotes/views/shim.js | 94 +++++++++++++++ lms/static/js/fixtures/edxnotes/edxnotes.html | 3 + lms/static/js/spec/edxnotes/shim_spec.js | 111 ++++++++++++++++++ lms/static/js/spec/main.js | 3 + 7 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 lms/static/js/spec/edxnotes/shim_spec.js diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index e04fd2d40534..5e24e885e70a 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -120,6 +120,21 @@ class EdxNotesUnitPage(CoursePage): 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 + @property def components(self): """ @@ -197,6 +212,8 @@ class EdxNoteHighlight(NoteChild): """ 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) @@ -204,6 +221,15 @@ def __init__(self, browser, element, parent_id): 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. @@ -217,7 +243,7 @@ def wait_for_viewer_visibility(self): Waiting for visibility of note viewer. """ self.wait_for_element_visibility( - self._bounded_selector(".annotator-viewer"), "Note Viewer is visible." + self._bounded_selector(self.VIEWER_SELECTOR), "Note Viewer is visible." ) def wait_for_editor_visibility(self): @@ -225,7 +251,7 @@ def wait_for_editor_visibility(self): Waiting for visibility of note editor. """ self.wait_for_element_visibility( - self._bounded_selector(".annotator-editor"), "Note Editor is visible." + self._bounded_selector(self.EDITOR_SELECTOR), "Note Editor is visible." ) def wait_for_notes_invisibility(self, text="Notes are hidden"): @@ -245,6 +271,20 @@ def select_and_click_adder(self): 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. diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py index 7b833dde2287..147d69829911 100644 --- a/common/test/acceptance/tests/helpers.py +++ b/common/test/acceptance/tests/helpers.py @@ -9,6 +9,7 @@ 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 index f66853091ce3..e720a014d51f 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -9,16 +9,16 @@ from ...fixtures.edxnotes import EdxNotesFixture, Note, Range -class EdxNotesTest(UniqueCourseTest): +class EdxNotesTestMixin(UniqueCourseTest): """ - Tests for annotation inside HTML components in LMS. + Creates a course with initial data and contains useful helper methods. """ - def setUp(self): """ Initialize pages and install a course fixture. """ - super(EdxNotesTest, self).setUp() + 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) @@ -84,11 +84,16 @@ def _add_notes(self): 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(".annotate-id"): + for note in component.create_note(".{}".format(self.selector)): note.text = "TEST TEXT {}".format(index) index += 1 @@ -122,12 +127,12 @@ def assert_text_in_notes(self, components, offset=0): def test_can_create_notes(self): """ Scenario: User can create notes. - Given I have a course with 3 annotatatble components - And I open the unit with 2 annotatatble components + 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 annotatatble component on the page + 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 @@ -157,7 +162,7 @@ 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 annotatatble components + 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" @@ -192,7 +197,7 @@ 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 annotatatble components + 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" @@ -387,3 +392,76 @@ def test_easy_access_from_notes_page(self): item.go_to_unit() self.courseware_page.wait_for_page() self.assertIn(text, self.courseware_page.xblock_component_html_content()) + + +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) diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index fc1ab91aad69..2e92109694f6 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -3,6 +3,25 @@ define(['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) + ); + }; + } + + Annotator.frozenSrc = null; + /** * Modifies Annotator.highlightRange to add a "tabindex=0" attribute * to the markup that encloses the note. @@ -16,6 +35,25 @@ 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) { + _.invoke(Annotator._instances, 'unfreeze'); + } else { + // Unfreeze only this instance and unbound associated 'click.edxnotes:freeze' handler + $(document).off('click.edxnotes:freeze'+this.uid); + this.isFrozen = false; + } + } + ); + /** * Modifies Annotator.Viewer.html.item template to add an i18n for the * buttons. @@ -35,5 +73,61 @@ '', '' ].join(''); + + $.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; + _.invoke(Annotator._instances, 'freeze'); + }, + + onNoteClick: function (event) { + event.stopPropagation(); + Annotator.Util.preventEventDefault(event); + if (!$(event.target).is('.annotator-delete')) { + Annotator.frozenSrc = this; + _.invoke(Annotator._instances, 'freeze'); + } + }, + + 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, this.unfreeze.bind(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; + } + } + }); }); }).call(this, define || RequireJS.define, jQuery, _); diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html index 3e73c2a1d319..9fa2c15ed389 100644 --- a/lms/static/js/fixtures/edxnotes/edxnotes.html +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -1,3 +1,6 @@
    Annotate it!
    +
    + Annotate it! +
    diff --git a/lms/static/js/spec/edxnotes/shim_spec.js b/lms/static/js/spec/edxnotes/shim_spec.js new file mode 100644 index 000000000000..83b2444c5ce5 --- /dev/null +++ b/lms/static/js/spec/edxnotes/shim_spec.js @@ -0,0 +1,111 @@ +define(['jquery', 'underscore', 'js/edxnotes/notes', 'jasmine-jquery'], + function($, _, Notes) { + 'use strict'; + + describe('Test 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)).toBeGreaterThan(-1); + }); + } + + beforeEach(function() { + loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + highlights = []; + annotators = [ + Notes.factory($('div#edx-notes-wrapper-123').get(0), {}), + Notes.factory($('div#edx-notes-wrapper-456').get(0), {}) + ]; + _.each(annotators, function(annotator, index) { + highlights.push($('').appendTo(annotators[index].element)); + spyOn(annotator, 'onHighlightClick').andCallThrough(); + spyOn(annotator, 'onHighlightMouseover').andCallThrough(); + spyOn(annotator, 'startViewerHideTimer').andCallThrough(); + }); + }); + + it('Test that 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, function(annotator) { + checkAnnotatorIsFrozen(annotator); + }); + }); + + it('Test that 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('Test that 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('Test that 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); + }); + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index b8522d108b1a..8efea245a05f 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -526,6 +526,9 @@ 'lms/include/js/spec/edxnotes/notes_spec.js', 'lms/include/js/spec/edxnotes/utils/logger_spec.js', 'lms/include/js/spec/edxnotes/views/notes_page_spec.js' + 'lms/include/js/spec/edxnotes/logger_spec.js', + 'lms/include/js/spec/edxnotes/notes_spec.js', + 'lms/include/js/spec/edxnotes/shim_spec.js' ]); }).call(this, requirejs, define); From f00efda4c9c571d49dd38b2c4f6c2e8356b9ebac Mon Sep 17 00:00:00 2001 From: polesye Date: Sat, 22 Nov 2014 22:00:31 +0200 Subject: [PATCH 04/47] TNL-731: Add possibility to search notes. --- cms/envs/test.py | 2 +- common/djangoapps/terrain/stubs/edxnotes.py | 13 +- common/static/js/vendor/jquery.highlight.js | 108 +++++ common/templates/edxnotes_wrapper.html | 2 +- common/test/acceptance/pages/lms/edxnotes.py | 158 ++++++- .../acceptance/tests/lms/test_lms_edxnotes.py | 196 ++++++--- lms/djangoapps/edxnotes/exceptions.py | 10 + lms/djangoapps/edxnotes/helpers.py | 138 ++++-- lms/djangoapps/edxnotes/tests.py | 414 +++++++++++++----- lms/djangoapps/edxnotes/urls.py | 11 + lms/djangoapps/edxnotes/views.py | 39 +- lms/envs/bok_choy.py | 2 +- lms/envs/common.py | 2 +- lms/static/js/edxnotes/collections/notes.js | 18 +- lms/static/js/edxnotes/collections/tabs.js | 12 + lms/static/js/edxnotes/models/note.js | 46 +- lms/static/js/edxnotes/models/tab.js | 33 ++ lms/static/js/edxnotes/utils/logger.js | 183 +++++--- lms/static/js/edxnotes/views/note_item.js | 28 -- lms/static/js/edxnotes/views/notes.js | 90 ---- lms/static/js/edxnotes/views/notes_factory.js | 95 ++++ lms/static/js/edxnotes/views/notes_page.js | 66 ++- lms/static/js/edxnotes/views/page_factory.js | 54 ++- .../js/edxnotes/views/recent_activity_view.js | 33 -- lms/static/js/edxnotes/views/search_box.js | 160 +++++++ lms/static/js/edxnotes/views/shim.js | 238 +++++----- lms/static/js/edxnotes/views/subview.js | 33 ++ lms/static/js/edxnotes/views/tab_item.js | 62 +++ lms/static/js/edxnotes/views/tab_view.js | 122 ++++++ .../js/edxnotes/views/tabs/recent_activity.js | 25 ++ .../js/edxnotes/views/tabs/search_results.js | 135 ++++++ lms/static/js/edxnotes/views/tabs_list.js | 41 ++ lms/static/js/fixtures/edxnotes/edxnotes.html | 27 +- .../fixtures/edxnotes/edxnotes_wrapper.html | 6 + .../js/spec/edxnotes/custom_matchers.js | 28 ++ .../js/spec/edxnotes/models/tab_spec.js | 33 ++ .../js/spec/edxnotes/notes_factory_spec.js | 38 ++ lms/static/js/spec/edxnotes/notes_spec.js | 35 -- lms/static/js/spec/edxnotes/shim_spec.js | 200 ++++----- .../js/spec/edxnotes/utils/logger_spec.js | 168 ++++--- .../js/spec/edxnotes/views/notes_page_spec.js | 69 ++- .../js/spec/edxnotes/views/search_box_spec.js | 153 +++++++ .../js/spec/edxnotes/views/tab_item_spec.js | 41 ++ .../js/spec/edxnotes/views/tab_view_spec.js | 106 +++++ .../views/tabs/recent_activity_spec.js | 75 ++++ .../views/tabs/search_results_spec.js | 206 +++++++++ .../js/spec/edxnotes/views/tabs_list_spec.js | 50 +++ lms/static/js/spec/main.js | 20 +- lms/static/js_test.yml | 1 + lms/static/require-config-lms.js | 2 +- lms/static/sass/course/_edxnotes.scss | 102 ++++- lms/templates/{ => edxnotes}/edxnotes.html | 34 +- lms/templates/edxnotes/note-item.underscore | 24 - .../edxnotes/recent-activity-item.underscore | 28 ++ lms/templates/edxnotes/tab-item.underscore | 4 + lms/urls.py | 2 +- 56 files changed, 3073 insertions(+), 948 deletions(-) create mode 100644 common/static/js/vendor/jquery.highlight.js create mode 100644 lms/djangoapps/edxnotes/exceptions.py create mode 100644 lms/djangoapps/edxnotes/urls.py create mode 100644 lms/static/js/edxnotes/collections/tabs.js create mode 100644 lms/static/js/edxnotes/models/tab.js delete mode 100644 lms/static/js/edxnotes/views/note_item.js delete mode 100644 lms/static/js/edxnotes/views/notes.js create mode 100644 lms/static/js/edxnotes/views/notes_factory.js delete mode 100644 lms/static/js/edxnotes/views/recent_activity_view.js create mode 100644 lms/static/js/edxnotes/views/search_box.js create mode 100644 lms/static/js/edxnotes/views/subview.js create mode 100644 lms/static/js/edxnotes/views/tab_item.js create mode 100644 lms/static/js/edxnotes/views/tab_view.js create mode 100644 lms/static/js/edxnotes/views/tabs/recent_activity.js create mode 100644 lms/static/js/edxnotes/views/tabs/search_results.js create mode 100644 lms/static/js/edxnotes/views/tabs_list.js create mode 100644 lms/static/js/fixtures/edxnotes/edxnotes_wrapper.html create mode 100644 lms/static/js/spec/edxnotes/custom_matchers.js create mode 100644 lms/static/js/spec/edxnotes/models/tab_spec.js create mode 100644 lms/static/js/spec/edxnotes/notes_factory_spec.js delete mode 100644 lms/static/js/spec/edxnotes/notes_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/search_box_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/tab_item_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/tab_view_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/tabs_list_spec.js rename lms/templates/{ => edxnotes}/edxnotes.html (59%) delete mode 100644 lms/templates/edxnotes/note-item.underscore create mode 100644 lms/templates/edxnotes/recent-activity-item.underscore create mode 100644 lms/templates/edxnotes/tab-item.underscore diff --git a/cms/envs/test.py b/cms/envs/test.py index cfa520c209cf..221ebc4a0e11 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -232,5 +232,5 @@ FEATURES['ENABLE_EDXNOTES'] = True EDXNOTES_INTERFACE = { - 'url': 'http://localhost:8042/', + 'url': 'http://localhost:8042/api/v1', } diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py index 38b43ff45775..56664ca061d4 100644 --- a/common/djangoapps/terrain/stubs/edxnotes.py +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -198,6 +198,7 @@ def _search(self): 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") @@ -209,6 +210,8 @@ def _search(self): results = self.server.filter_by_course_id(results, course_id) if usage_id is not None: results = self.server.filter_by_usage_id(results, usage_id) + if text: + results = self.server.search(results, text) self.respond(content={ "total": len(results), "rows": results, @@ -247,7 +250,9 @@ def get_notes(self): """ Returns a list of all notes. """ - return deepcopy(self.notes) + notes = deepcopy(self.notes) + notes.reverse() + return notes def add_notes(self, notes): """ @@ -318,3 +323,9 @@ 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")] diff --git a/common/static/js/vendor/jquery.highlight.js b/common/static/js/vendor/jquery.highlight.js new file mode 100644 index 000000000000..9dcf3c7af3ff --- /dev/null +++ b/common/static/js/vendor/jquery.highlight.js @@ -0,0 +1,108 @@ +/* + * jQuery Highlight plugin + * + * Based on highlight v3 by Johann Burkard + * http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html + * + * Code a little bit refactored and cleaned (in my humble opinion). + * Most important changes: + * - has an option to highlight only entire words (wordsOnly - false by default), + * - has an option to be case sensitive (caseSensitive - false by default) + * - highlight element tag and class names can be specified in options + * + * Usage: + * // wrap every occurrance of text 'lorem' in content + * // with (default options) + * $('#content').highlight('lorem'); + * + * // search for and highlight more terms at once + * // so you can save some time on traversing DOM + * $('#content').highlight(['lorem', 'ipsum']); + * $('#content').highlight('lorem ipsum'); + * + * // search only for entire word 'lorem' + * $('#content').highlight('lorem', { wordsOnly: true }); + * + * // don't ignore case during search of term 'lorem' + * $('#content').highlight('lorem', { caseSensitive: true }); + * + * // wrap every occurrance of term 'ipsum' in content + * // with + * $('#content').highlight('ipsum', { element: 'em', className: 'important' }); + * + * // remove default highlight + * $('#content').unhighlight(); + * + * // remove custom highlight + * $('#content').unhighlight({ element: 'em', className: 'important' }); + * + * + * Copyright (c) 2009 Bartek Szopka + * + * Licensed under MIT license. + * + */ + +jQuery.extend({ + highlight: function (node, re, nodeName, className) { + if (node.nodeType === 3) { + var match = node.data.match(re); + if (match) { + var highlight = document.createElement(nodeName || 'span'); + highlight.className = className || 'highlight'; + var wordNode = node.splitText(match.index); + wordNode.splitText(match[0].length); + var wordClone = wordNode.cloneNode(true); + highlight.appendChild(wordClone); + wordNode.parentNode.replaceChild(highlight, wordNode); + return 1; //skip added node in parent + } + } else if ((node.nodeType === 1 && node.childNodes) && // only element nodes that have children + !/(script|style)/i.test(node.tagName) && // ignore script and style nodes + !(node.tagName === nodeName.toUpperCase() && node.className === className)) { // skip if already highlighted + for (var i = 0; i < node.childNodes.length; i++) { + i += jQuery.highlight(node.childNodes[i], re, nodeName, className); + } + } + return 0; + } +}); + +jQuery.fn.unhighlight = function (options) { + var settings = { className: 'highlight', element: 'span' }; + jQuery.extend(settings, options); + + return this.find(settings.element + "." + settings.className).each(function () { + var parent = this.parentNode; + parent.replaceChild(this.firstChild, this); + parent.normalize(); + }).end(); +}; + +jQuery.fn.highlight = function (words, options) { + var settings = { className: 'highlight', element: 'span', caseSensitive: false, wordsOnly: false }; + jQuery.extend(settings, options); + + if (words.constructor === String) { + words = [words]; + } + words = jQuery.grep(words, function(word, i){ + return word != ''; + }); + words = jQuery.map(words, function(word, i) { + return word.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + }); + if (words.length == 0) { return this; }; + + var flag = settings.caseSensitive ? "" : "i"; + var pattern = "(" + words.join("|") + ")"; + if (settings.wordsOnly) { + pattern = "\\b" + pattern + "\\b"; + } + var re = new RegExp(pattern, flag); + + return this.each(function () { + jQuery.highlight(this, re, settings.element, settings.className); + }); +}; + diff --git a/common/templates/edxnotes_wrapper.html b/common/templates/edxnotes_wrapper.html index 52d5f4255eda..3030d31e8078 100644 --- a/common/templates/edxnotes_wrapper.html +++ b/common/templates/edxnotes_wrapper.html @@ -8,7 +8,7 @@ @@ -43,10 +55,8 @@

    ${_('Notes')}

    var pageView = new NotesFactory({ notesList: ${notes}, debugMode: ${debug}, - authToken: '${token}', user: '${user.username}', - courseId: '${course.id}', - endpoint: '${endpoint}' + courseId: '${course.id}' }); }); }).call(this, require || RequireJS.require); diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore deleted file mode 100644 index cd3ee4179b29..000000000000 --- a/lms/templates/edxnotes/note-item.underscore +++ /dev/null @@ -1,24 +0,0 @@ -
    -<% if (quote) { %> -
    <%- quote %>
    -<% } %> -<% if (text) { %> -
    <%- text %>
    -<% } %> -
    -
    -
    - <% if (text && quote) { %> -
    <%- gettext("Highlighted & Noted in:") %>
    - <% } else if (text) { %> -
    <%- gettext("Highlighted in:") %>
    - <% } else if (quote) { %> -
    <%- gettext("Noted in:") %>
    - <% } %> -
    <%- unit.display_name %>
    - <% if (updated) { %> -
    <%- gettext("Last Edited:") %>
    -
    <%- updated %>
    - <% } %> -
    -
    diff --git a/lms/templates/edxnotes/recent-activity-item.underscore b/lms/templates/edxnotes/recent-activity-item.underscore new file mode 100644 index 000000000000..3d997880ed9a --- /dev/null +++ b/lms/templates/edxnotes/recent-activity-item.underscore @@ -0,0 +1,28 @@ +<% collection.each(function (model) { %> +
    +
    + <% if (model.get('quote')) { %> +
    <%= model.escape('quote') %>
    + <% } %> + <% if (model.get('text')) { %> +
    <%= model.escape('text') %>
    + <% } %> +
    +
    +
    + <% if (model.get('text') && model.get('quote')) { %> +
    <%- gettext("Highlighted & Noted in:") %>
    + <% } else if (model.get('text')) { %> +
    <%- gettext("Highlighted in:") %>
    + <% } else if (model.get('quote')) { %> +
    <%- gettext("Noted in:") %>
    + <% } %> +
    <%- model.get('unit').display_name %>
    + <% if (model.get('updated')) { %> +
    <%- gettext("Last Edited:") %>
    +
    <%- model.get('updated') %>
    + <% } %> +
    +
    +
    +<% }) %> diff --git a/lms/templates/edxnotes/tab-item.underscore b/lms/templates/edxnotes/tab-item.underscore new file mode 100644 index 000000000000..a5eff00dc619 --- /dev/null +++ b/lms/templates/edxnotes/tab-item.underscore @@ -0,0 +1,4 @@ +<%- gettext(name) %> +<% if (is_closable) { %> + x +<% } %> diff --git a/lms/urls.py b/lms/urls.py index b09209a55bb9..7c1372fbe1f9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -382,7 +382,7 @@ url(r'^profile/', include('student_profile.urls')), # Student Notes - url(r'^courses/{}/edxnotes$'.format(settings.COURSE_ID_PATTERN), 'edxnotes.views.edxnotes', name='edxnotes'), + 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 From 861e91d9af41099e47c057421e4d9a163d04f58c Mon Sep 17 00:00:00 2001 From: Tim Babych Date: Mon, 1 Dec 2014 20:36:13 +0200 Subject: [PATCH 05/47] TNL-782 use JWT ID-Token for authentication annotation requests --- common/djangoapps/terrain/stubs/edxnotes.py | 13 ++-- .../terrain/stubs/tests/test_edxnotes.py | 7 -- common/templates/edxnotes_wrapper.html | 3 +- lms/djangoapps/edxnotes/decorators.py | 26 +++---- lms/djangoapps/edxnotes/helpers.py | 37 +++++++--- lms/djangoapps/edxnotes/tests.py | 72 ++++++++++++++++--- lms/djangoapps/edxnotes/urls.py | 7 +- lms/djangoapps/edxnotes/views.py | 10 +++ lms/static/js/edxnotes/views/notes_factory.js | 22 +++--- lms/static/js/edxnotes/views/shim.js | 26 +++++++ .../js/spec/edxnotes/notes_factory_spec.js | 64 +++++++++++++++-- lms/templates/edxnotes/edxnotes.html | 3 +- 12 files changed, 218 insertions(+), 72 deletions(-) diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py index 56664ca061d4..d1e82aa2dd95 100644 --- a/common/djangoapps/terrain/stubs/edxnotes.py +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -205,16 +205,15 @@ def _search(self): return notes = self.server.get_notes() - results = self.server.filter_by_user(notes, user) if course_id is not None: - results = self.server.filter_by_course_id(results, course_id) + notes = self.server.filter_by_course_id(notes, course_id) if usage_id is not None: - results = self.server.filter_by_usage_id(results, usage_id) + notes = self.server.filter_by_usage_id(notes, usage_id) if text: - results = self.server.search(results, text) + notes = self.server.search(notes, text) self.respond(content={ - "total": len(results), - "rows": results, + "total": len(notes), + "rows": notes, }) def _collection(self): @@ -226,7 +225,7 @@ def _collection(self): self.send_response(400, content="Bad Request") return notes = self.server.get_notes() - self.respond(content=self.server.filter_by_user(notes, user)) + self.respond(content=notes) def _cleanup(self): """ diff --git a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py index 10055045a13c..b75411397f2d 100644 --- a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py +++ b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py @@ -107,13 +107,6 @@ def test_search(self): self.assertTrue(response.ok) self.assertDictEqual({"total": 2, "rows": notes}, response.json()) - response = requests.get(self._get_url("api/v1/search"), params={ - "user": "user-without-notes", - "usage_id": "dummy-usage-id", - "course_id": "dummy-course-id", - }) - self.assertDictEqual({"total": 0, "rows": []}, response.json()) - response = requests.get(self._get_url("api/v1/search")) self.assertEqual(response.status_code, 400) diff --git a/common/templates/edxnotes_wrapper.html b/common/templates/edxnotes_wrapper.html index 3030d31e8078..a275f52d479d 100644 --- a/common/templates/edxnotes_wrapper.html +++ b/common/templates/edxnotes_wrapper.html @@ -1,7 +1,8 @@ <%! import json %> +<%! from student.models import anonymous_id_for_user %> <% if user: - params.update({'user': user.username}) + params.update({'user': anonymous_id_for_user(user, None)}) %>
    ${content}
    diff --git a/lms/djangoapps/edxnotes/decorators.py b/lms/djangoapps/edxnotes/decorators.py index d9a4170c6918..f40042db8cf2 100644 --- a/lms/djangoapps/edxnotes/decorators.py +++ b/lms/djangoapps/edxnotes/decorators.py @@ -1,14 +1,15 @@ """ Decorators related to edXNotes. """ +from django.conf import settings from edxnotes.helpers import ( get_endpoint, - get_token, + get_id_token, + get_token_url, generate_uid, is_feature_enabled, ) from edxmako.shortcuts import render_to_string -from django.conf import settings def edxnotes(cls): @@ -21,7 +22,7 @@ def get_html(self, *args, **kwargs): """ Returns raw html for the component. """ - is_studio = getattr(self.system, 'is_author_mode', False) + 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 or depends on the feature flag/advanced @@ -29,16 +30,17 @@ def get_html(self, *args, **kwargs): 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(), - 'params': { + return render_to_string("edxnotes_wrapper.html", { + "content": original_get_html(self, *args, **kwargs), + "uid": generate_uid(), + "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_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)), - 'endpoint': get_endpoint(), - 'debug': settings.DEBUG, + "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, }, }) diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 536d4178d3a1..b69671f6d4c9 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -12,11 +12,14 @@ from django.core.urlresolvers import reverse from django.core.exceptions import ImproperlyConfigured from django.utils.translation import ugettext as _ + +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 .exceptions import EdxNotesParseError log = logging.getLogger(__name__) @@ -33,20 +36,36 @@ def default(self, obj): return json.JSONEncoder.default(self, obj) -def get_token(user): +def get_id_token(user): """ - Generates OAuth access token for a user. + Generates JWT ID-Token, using or creating user's OAuth access token. """ try: - token = AccessToken.objects.get( - client=Client.objects.get(name="edx-notes"), + 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: - token = AccessToken(client=Client.objects.get(name="edx-notes"), user=user) - token.save() - return token.token + 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": course_id.to_deprecated_string(), + }) def send_request(user, course_id, path="", query_string=""): @@ -55,7 +74,7 @@ def send_request(user, course_id, path="", query_string=""): """ url = get_endpoint(path) params = { - "user": user.username, + "user": anonymous_id_for_user(user, None), "course_id": unicode(course_id).encode("utf-8"), } @@ -67,7 +86,7 @@ def send_request(user, course_id, path="", query_string=""): response = requests.get( url, headers={ - "x-annotator-auth-token": get_token(user) + "x-annotator-auth-token": get_id_token(user) }, params=params ) diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 6b3aa030d837..52e5dade6636 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -2,15 +2,18 @@ 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.decorators import edxnotes 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.django import modulestore @@ -65,14 +68,37 @@ def setUp(self): self.client.login(username=self.user.username, password="edx") self.problem = TestProblem(self.course) - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) - def test_edxnotes_enabled(self): + @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) - self.assertIn("edx-notes-wrapper", self.problem.get_html()) + expected_context = { + "content": "original_get_html", + "uid": "uid", + "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): @@ -431,13 +457,15 @@ def test_get_ancestor_context_no_parent(self, mock_get_ancestor): ) @patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}) - @patch("edxnotes.helpers.get_token") + @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, get_token): + 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. """ - get_token.return_value = "test_token" + 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" ) @@ -447,20 +475,22 @@ def test_send_request_with_query_string(self, mock_get, get_token): "x-annotator-auth-token": "test_token" }, params={ - "user": self.user.username, + "user": "anonymous_id", "course_id": unicode(self.course.id), "text": "text", } ) @patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}) - @patch("edxnotes.helpers.get_token") + @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, get_token): + 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. """ - get_token.return_value = "test_token" + 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" ) @@ -470,7 +500,7 @@ def test_send_request_without_query_string(self, mock_get, get_token): "x-annotator-auth-token": "test_token" }, params={ - "user": self.user.username, + "user": "anonymous_id", "course_id": unicode(self.course.id), } ) @@ -489,6 +519,7 @@ def setUp(self): 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)]) # pylint: disable=unused-argument @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @@ -567,3 +598,22 @@ def test_search_notes_exception(self, mock_search): 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) diff --git a/lms/djangoapps/edxnotes/urls.py b/lms/djangoapps/edxnotes/urls.py index aabd156f2082..942c5201910e 100644 --- a/lms/djangoapps/edxnotes/urls.py +++ b/lms/djangoapps/edxnotes/urls.py @@ -5,7 +5,8 @@ # 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"), + "edxnotes.views", + url(r"^/$", "edxnotes", name="edxnotes"), + url(r"^/search/$", "search_notes", name="search_notes"), + url(r"^/token/$", "get_token", name="get_token"), ) diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 8e888a771f11..4d2644bc1ed1 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -13,6 +13,7 @@ from edxnotes.exceptions import EdxNotesParseError from edxnotes.helpers import ( get_notes, + get_id_token, is_feature_enabled, search ) @@ -61,3 +62,12 @@ def search_notes(request, course_id): 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') diff --git a/lms/static/js/edxnotes/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js index 893319b8c1f4..b52d35c66b51 100644 --- a/lms/static/js/edxnotes/views/notes_factory.js +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -3,7 +3,7 @@ define([ 'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger', 'js/edxnotes/views/shim' ], function ($, _, Annotator, Logger) { - var plugins = ['Store'], + var plugins = ['Auth', 'Store'], getOptions, setupPlugins, updateHeaders, getAnnotator; /** @@ -13,6 +13,8 @@ define([ * @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) { @@ -24,6 +26,10 @@ define([ prefix = params.endpoint.replace(/(.+)\/$/, '$1'); return { + auth: { + token: params.token, + tokenUrl: params.tokenUrl + }, store: { prefix: prefix, annotationData: defaultParams, @@ -39,18 +45,6 @@ define([ }; }; - /** - * Updates request headers. - * @param {jQuery Element} The container element. - * @param {String} token An authentication token. - **/ - updateHeaders = function (element, token) { - var current = element.data('annotator:headers'); - element.data('annotator:headers', $.extend(current, { - 'x-annotator-auth-token': token - })); - }; - /** * Setups plugins for the annotator. * @param {Object} annotator An instance of the annotator. @@ -72,6 +66,7 @@ define([ * @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) { @@ -80,7 +75,6 @@ define([ logger = Logger.getLogger(element.id, params.debug), annotator; - updateHeaders(el, params.token); annotator = el.annotator(options).data('annotator'); setupPlugins(annotator, plugins, options); annotator.logger = logger; diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index cd093f1602fb..b928fdbde6cc 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -22,6 +22,32 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { 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. diff --git a/lms/static/js/spec/edxnotes/notes_factory_spec.js b/lms/static/js/spec/edxnotes/notes_factory_spec.js index 033b2995d052..e809c43e47de 100644 --- a/lms/static/js/spec/edxnotes/notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/notes_factory_spec.js @@ -4,6 +4,53 @@ define([ ], function($, Notes, AjaxHelpers) { 'use strict'; + var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + base64Encode, makeToken; + + 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'; + }; + describe('EdxNotes Notes', function() { var wrapper; @@ -14,25 +61,28 @@ function($, Notes, AjaxHelpers) { it('Tests that annotator is initialized with options correctly', function() { var requests = AjaxHelpers.requests(this), - internalOptions = { + token = makeToken(), + annotationData = { user: 'a user', usage_id : 'an usage', course_id: 'a course' }, annotator = Notes.factory(wrapper[0], { - endpoint: 'test_endpoint', + endpoint: '/test_endpoint', user: 'a user', usageId : 'an usage', courseId: 'a course', - token: 'test_token' + token: token, + tokenUrl: '/test_token_url' }), request = requests[0]; expect(requests.length).toBe(1); - expect(request.requestHeaders['x-annotator-auth-token']).toBe('test_token'); - expect(annotator.options.store.prefix).toBe('test_endpoint'); - expect(annotator.options.store.annotationData).toEqual(internalOptions); - expect(annotator.options.store.loadFromSearch).toEqual(internalOptions); + 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(annotationData); + expect(annotator.options.store.loadFromSearch).toEqual(annotationData); }); }); }); diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 914a5426af54..07ab12dfe5e0 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -1,5 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%! import json %> +<%! from student.models import anonymous_id_for_user %> <%namespace name='static' file='/static_content.html'/> <%inherit file="/main.html" /> @@ -55,7 +56,7 @@

    ${_('View notes by:')}

    var pageView = new NotesFactory({ notesList: ${notes}, debugMode: ${debug}, - user: '${user.username}', + user: '${anonymous_id_for_user(user, None)}', courseId: '${course.id}' }); }); From ef894e882fc2e1edb6c53dec035f36eed9750c22 Mon Sep 17 00:00:00 2001 From: Jean-Michel Claus Date: Sat, 22 Nov 2014 14:50:41 +0100 Subject: [PATCH 06/47] TNL-661: Toggle all notes --- .../models/settings/course_metadata.py | 1 + .../xmodule/modulestore/inheritance.py | 6 ++ .../css/vendor/edxnotes/annotator.min.css | 3 +- common/templates/edxnotes_wrapper.html | 4 +- common/test/acceptance/pages/lms/edxnotes.py | 7 ++ .../acceptance/tests/lms/test_lms_edxnotes.py | 71 +++++++++++++++- lms/djangoapps/edxnotes/decorators.py | 4 + lms/djangoapps/edxnotes/helpers.py | 1 + lms/djangoapps/edxnotes/tests.py | 61 +++++++++++++- lms/djangoapps/edxnotes/urls.py | 1 + lms/djangoapps/edxnotes/views.py | 31 ++++++- lms/static/js/edxnotes/views/shim.js | 40 +++++---- .../js/edxnotes/views/toggle_notes_factory.js | 73 ++++++++++++++++ .../js/edxnotes/views/visibility_decorator.js | 74 ++++++++++++++++ .../js/fixtures/edxnotes/toggle_notes.html | 7 ++ .../{notes_factory_spec.js => base64.js} | 44 ++-------- .../spec/edxnotes/views/notes_factory_spec.js | 45 ++++++++++ .../js/spec/edxnotes/{ => views}/shim_spec.js | 13 ++- .../views/toggle_notes_factory_spec.js | 84 +++++++++++++++++++ .../views/visibility_decorator_spec.js | 56 +++++++++++++ lms/static/js/spec/main.js | 7 +- lms/static/sass/_developer.scss | 9 ++ lms/static/sass/course/_edxnotes.scss | 6 ++ lms/templates/courseware/courseware.html | 4 + lms/templates/edxnotes/toggle_notes.html | 27 ++++++ 25 files changed, 610 insertions(+), 69 deletions(-) create mode 100644 lms/static/js/edxnotes/views/toggle_notes_factory.js create mode 100644 lms/static/js/edxnotes/views/visibility_decorator.js create mode 100644 lms/static/js/fixtures/edxnotes/toggle_notes.html rename lms/static/js/spec/edxnotes/{notes_factory_spec.js => base64.js} (50%) create mode 100644 lms/static/js/spec/edxnotes/views/notes_factory_spec.js rename lms/static/js/spec/edxnotes/{ => views}/shim_spec.js (91%) create mode 100644 lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js create mode 100644 lms/templates/edxnotes/toggle_notes.html diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 78e45ef5f00a..67fe113954b0 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -33,6 +33,7 @@ class CourseMetadata(object): 'tags', # from xblock 'visible_to_staff_only', 'group_access', + 'edxnotes_visibility', ] @classmethod diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 82aa07fe8b5c..19e0945a00c3 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -178,6 +178,12 @@ class InheritanceMixin(XBlockMixin): default=False, scope=Scope.settings ) + edxnotes_visibility = Boolean( + display_name=_("Enable visibility of Notes"), + help=_("Enter true or false. If true, Notes for HTML components will be visible."), + default=True, + scope=Scope.user_info + ) def compute_inherited_metadata(descriptor): diff --git a/common/static/css/vendor/edxnotes/annotator.min.css b/common/static/css/vendor/edxnotes/annotator.min.css index 0584acfa2487..ec7450912590 100644 --- a/common/static/css/vendor/edxnotes/annotator.min.css +++ b/common/static/css/vendor/edxnotes/annotator.min.css @@ -1 +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} \ No newline at end of file +.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/templates/edxnotes_wrapper.html b/common/templates/edxnotes_wrapper.html index a275f52d479d..4675d4fd598b 100644 --- a/common/templates/edxnotes_wrapper.html +++ b/common/templates/edxnotes_wrapper.html @@ -9,9 +9,9 @@
    diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index fd4a57a0666c..941f79c88c0b 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -251,6 +251,13 @@ def click(self, selector): 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): """ diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 0b759a32bd4c..9dfbaaacd93e 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -39,7 +39,7 @@ def setUp(self): self.course_fixture.add_children( XBlockFixtureDesc("chapter", "Test Section").add_children( - XBlockFixtureDesc("sequential", "Test Subsection").add_children( + XBlockFixtureDesc("sequential", "Test Subsection 1").add_children( XBlockFixtureDesc("vertical", "Test Vertical").add_children( XBlockFixtureDesc( "html", @@ -61,6 +61,18 @@ def setUp(self): data="""

    Annotate this text!

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

    Annotate this text!

    +

    Annotate this text

    + """.format(self.selector) + ), + ), + ), )).install() AutoAuthPage(self.browser, username=self.username, email=self.email, course_id=self.course_id).visit() @@ -521,3 +533,60 @@ def test_interaction_between_notes(self): 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", 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", u"Test Subsection 2") + self.assertGreater(len(self.note_unit_page.notes), 0) diff --git a/lms/djangoapps/edxnotes/decorators.py b/lms/djangoapps/edxnotes/decorators.py index f40042db8cf2..a9eb466ef841 100644 --- a/lms/djangoapps/edxnotes/decorators.py +++ b/lms/djangoapps/edxnotes/decorators.py @@ -2,6 +2,7 @@ Decorators related to edXNotes. """ from django.conf import settings +import json from edxnotes.helpers import ( get_endpoint, get_id_token, @@ -33,6 +34,9 @@ def get_html(self, *args, **kwargs): 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"), diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index b69671f6d4c9..95440c9d87f1 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -22,6 +22,7 @@ import oauth2_provider.oidc as oidc from provider.utils import now from .exceptions import EdxNotesParseError + log = logging.getLogger(__name__) diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 52e5dade6636..e0dec9121a6a 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -14,11 +14,12 @@ 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.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.exceptions import ItemNotFoundError +from courseware.model_data import FieldDataCache +from courseware.module_render import get_module_for_descriptor from student.tests.factories import UserFactory from .exceptions import EdxNotesParseError @@ -86,6 +87,7 @@ def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_t 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"), @@ -520,6 +522,14 @@ def setUp(self): 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}) @@ -532,7 +542,6 @@ def test_edxnotes_view_is_enabled(self, mock_get_notes): response = self.client.get(self.notes_page_url) self.assertContains(response, "

    Notes

    ") - # pylint: disable=unused-argument @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_view_is_disabled(self): """ @@ -617,3 +626,51 @@ def test_get_id_token_anonymous(self): 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 index 942c5201910e..17dc36b5e5a6 100644 --- a/lms/djangoapps/edxnotes/urls.py +++ b/lms/djangoapps/edxnotes/urls.py @@ -9,4 +9,5 @@ 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 index 4d2644bc1ed1..608672788d6f 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -2,14 +2,17 @@ Views related to EdxNotes. """ import json +import logging from django.contrib.auth.decorators import login_required from django.core.urlresolvers import reverse from django.http import HttpResponse, HttpResponseBadRequest, Http404 from django.conf import settings -from util.json_request import JsonResponseBadRequest from edxmako.shortcuts import render_to_response from opaque_keys.edx.locations import SlashSeparatedCourseKey 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 from edxnotes.helpers import ( get_notes, @@ -18,6 +21,8 @@ search ) +log = logging.getLogger(__name__) + @login_required def edxnotes(request, course_id): @@ -71,3 +76,27 @@ 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') + + +def edxnotes_visibility(request, course_id): + """ + Handle ajax call from "Show notes" checkbox. + """ + course_key = SlashSeparatedCourseKey.from_deprecated_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: '{0}'".format(request.body) + ) + return JsonResponseBadRequest() diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index b928fdbde6cc..6900892f8937 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -13,13 +13,21 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { * 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) + $.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; /** @@ -90,17 +98,17 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { **/ Annotator.Viewer.prototype.html.item = [ '
  • ', - '', - '', - _t('View as webpage'), - '', - '', - '', - '', + '', + '', + _t('View as webpage'), + '', + '', + '', + '', '
  • ' ].join(''); @@ -133,7 +141,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { } }, - freeze: function() { + freeze: function () { if (!this.isFrozen) { // Remove default events this.removeEvents(); @@ -144,7 +152,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { } }, - unfreeze: function() { + unfreeze: function () { if (this.isFrozen) { // Add default events this.addEvents(); 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 000000000000..048c7424d19d --- /dev/null +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -0,0 +1,73 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/views/visibility_decorator' +], function($, _, Backbone, gettext, EdxnotesVisibilityDecorator) { + var ToggleNotesView = Backbone.View.extend({ + events: { + 'click .action-toggle-notes': 'toogleHandler' + }, + + errorMessage: gettext('Cannot save your state. This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'), + + initialize: function (options) { + this.visibility = options.visibility; + this.visibilityUrl = options.visibilityUrl; + this.checkboxIcon = this.$('.checkbox-icon'); + this.$('.action-toggle-notes').removeClass('is-disabled'); + }, + + toogleHandler: function (event) { + event.preventDefault(); + this.visibility = !this.visibility; + this.toggleNotes(); + this.sendRequest(); + }, + + toggleNotes: function () { + if (this.visibility) { + _.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote); + this.checkboxIcon.removeClass('icon-check-empty').addClass('icon-check'); + } else { + EdxnotesVisibilityDecorator.disableNotes(); + this.checkboxIcon.removeClass('icon-check').addClass('icon-check-empty'); + } + }, + + hideErrorMessage: function() { + this.$('.edx-notes-visibility-error').text(''); + }, + + showErrorMessage: function(message) { + this.$('.edx-notes-visibility-error').text(message); + }, + + sendRequest: function () { + return $.ajax({ + type: 'PUT', + url: this.visibilityUrl, + dataType: 'json', + data: JSON.stringify({'visibility': this.visibility}), + success: _.bind(this.onSuccess, this), + error: _.bind(this.onError, this) + }); + }, + + 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 000000000000..6436613f2884 --- /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/toggle_notes.html b/lms/static/js/fixtures/edxnotes/toggle_notes.html new file mode 100644 index 000000000000..2f0c9a80040b --- /dev/null +++ b/lms/static/js/fixtures/edxnotes/toggle_notes.html @@ -0,0 +1,7 @@ + diff --git a/lms/static/js/spec/edxnotes/notes_factory_spec.js b/lms/static/js/spec/edxnotes/base64.js similarity index 50% rename from lms/static/js/spec/edxnotes/notes_factory_spec.js rename to lms/static/js/spec/edxnotes/base64.js index e809c43e47de..2931a7f50286 100644 --- a/lms/static/js/spec/edxnotes/notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/base64.js @@ -1,8 +1,4 @@ -define([ - 'jquery', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers', - 'jasmine-jquery' -], -function($, Notes, AjaxHelpers) { +define([], function() { 'use strict'; var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", base64Encode, makeToken; @@ -51,38 +47,8 @@ function($, Notes, AjaxHelpers) { return 'header.' + base64Encode(JSON.stringify(rawToken)) + '.signature'; }; - describe('EdxNotes Notes', function() { - var wrapper; - - beforeEach(function() { - loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); - wrapper = $('div#edx-notes-wrapper-123'); - }); - - it('Tests that annotator is initialized with options correctly', function() { - var requests = AjaxHelpers.requests(this), - token = makeToken(), - annotationData = { - user: 'a user', - usage_id : 'an usage', - course_id: 'a course' - }, - annotator = Notes.factory(wrapper[0], { - endpoint: '/test_endpoint', - user: 'a user', - usageId : 'an usage', - courseId: 'a course', - token: token, - tokenUrl: '/test_token_url' - }), - request = requests[0]; - - expect(requests.length).toBe(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(annotationData); - expect(annotator.options.store.loadFromSearch).toEqual(annotationData); - }); - }); + return { + base64Encode: base64Encode, + makeToken: makeToken + } }); 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 000000000000..96f842542532 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js @@ -0,0 +1,45 @@ +define([ + 'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers', + 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/base64' +], function(Annotator, NotesFactory, AjaxHelpers, customMatchers, base64) { + 'use strict'; + describe('EdxNotes NotesFactory', function() { + var wrapper; + + 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 = base64.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/shim_spec.js b/lms/static/js/spec/edxnotes/views/shim_spec.js similarity index 91% rename from lms/static/js/spec/edxnotes/shim_spec.js rename to lms/static/js/spec/edxnotes/views/shim_spec.js index 81fd684256f4..0f82f4ed36e3 100644 --- a/lms/static/js/spec/edxnotes/shim_spec.js +++ b/lms/static/js/spec/edxnotes/views/shim_spec.js @@ -1,5 +1,6 @@ -define(['jquery', 'underscore', 'js/edxnotes/views/notes_factory', 'jasmine-jquery'], -function($, _, Notes) { +define([ + 'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory', 'jasmine-jquery' +], function($, _, Annotator, NotesFactory) { 'use strict'; describe('EdxNotes Shim', function() { var annotators, highlights; @@ -28,10 +29,10 @@ function($, _, Notes) { loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); highlights = []; annotators = [ - Notes.factory($('div#edx-notes-wrapper-123').get(0), { + NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), { endpoint: 'http://example.com/' }), - Notes.factory($('div#edx-notes-wrapper-456').get(0), { + NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), { endpoint: 'http://example.com/' }) ]; @@ -43,6 +44,10 @@ function($, _, Notes) { }); }); + 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); 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 000000000000..9b682e6b6989 --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js @@ -0,0 +1,84 @@ +define([ + 'jquery', 'annotator', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/visibility_decorator', + 'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/base64', + 'jasmine-jquery' +], function( + $, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, customMatchers, base64 +) { + 'use strict'; + describe('EdxNotes ToggleNotesFactory', function() { + var params = { + endpoint: '/test_endpoint', + user: 'a user', + usageId : 'an usage', + courseId: 'a course', + token: base64.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.icon = this.button.find('.checkbox-icon'); + }); + + afterEach(function () { + VisibilityDecorator._setVisibility(null); + _.invoke(Annotator._instances, 'destroy'); + }); + + it('can toggle notes', function() { + var requests = AjaxHelpers.requests(this); + + expect(this.button).not.toHaveClass('is-disabled'); + expect(this.icon).toHaveClass('icon-check'); + expect(this.icon).not.toHaveClass('icon-check-empty'); + + this.button.click(); + expect(this.icon).toHaveClass('icon-check-empty'); + expect(this.icon).not.toHaveClass('icon-check'); + expect(Annotator._instances).toHaveLength(0); + + AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', { + 'visibility': false + }); + AjaxHelpers.respondWithJson(requests, {}); + + this.button.click(); + expect(this.icon).toHaveClass('icon-check'); + expect(this.icon).not.toHaveClass('icon-check-empty'); + 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 = $('.edx-notes-visibility-error'); + + this.button.click(); + AjaxHelpers.respondWithError(requests); + expect(errorContainer).toContainText( + 'Cannot save your state. This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.' + ); + + this.button.click(); + AjaxHelpers.respondWithJson(requests, {}); + expect(errorContainer).toBeEmpty(); + }); + }); +}); 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 000000000000..9e57a9676933 --- /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/custom_matchers', 'js/spec/edxnotes/base64' +], function(Annotator, VisibilityDecorator, customMatchers, base64) { + 'use strict'; + describe('EdxNotes VisibilityDecorator', function() { + var params = { + endpoint: '/test_endpoint', + user: 'a user', + usageId : 'an usage', + courseId: 'a course', + token: base64.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 af8e7cd1a0b1..3a2d00262147 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: { @@ -528,9 +527,9 @@ '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/edxnotes/notes_factory_spec.js', - 'lms/include/js/spec/edxnotes/shim_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/notes_page_spec.js', 'lms/include/js/spec/edxnotes/views/search_box_spec.js', 'lms/include/js/spec/edxnotes/views/tabs_list_spec.js', @@ -538,6 +537,8 @@ '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/visibility_decorator_spec.js', + 'lms/include/js/spec/edxnotes/views/toggle_notes_factory_spec.js', 'lms/include/js/spec/edxnotes/models/tab_spec.js' ]); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 8b408cc0aeae..586a7fcd0b83 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -70,6 +70,15 @@ } } +/* 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% { diff --git a/lms/static/sass/course/_edxnotes.scss b/lms/static/sass/course/_edxnotes.scss index 293569e26d62..ebbe8221f3eb 100644 --- a/lms/static/sass/course/_edxnotes.scss +++ b/lms/static/sass/course/_edxnotes.scss @@ -1,3 +1,9 @@ +.edx-notes-visibility { + .error { + color: $red; + } +} + .edx-notes-page-wrapper { header { @include clearfix; diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index cdd5b53877a0..9cb22da25f6f 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()"> @@ -210,6 +211,9 @@
    ${fragment.body_html()} + % if is_edxnotes_enabled(course): + <%include file="/edxnotes/toggle_notes.html" args="course=course"/> + % endif
    diff --git a/lms/templates/edxnotes/toggle_notes.html b/lms/templates/edxnotes/toggle_notes.html new file mode 100644 index 000000000000..600ef61c3024 --- /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}) +%> + + From 426f2370b6cb5a3f6bda3966566872fe2a321098 Mon Sep 17 00:00:00 2001 From: polesye Date: Thu, 4 Dec 2014 17:56:56 +0200 Subject: [PATCH 07/47] Add more graceful error message. --- lms/djangoapps/edxnotes/exceptions.py | 7 +++++++ lms/djangoapps/edxnotes/helpers.py | 26 ++++++++++++++------------ lms/djangoapps/edxnotes/tests.py | 25 ++++++++++++++++++++++++- lms/djangoapps/edxnotes/views.py | 10 +++++++--- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/lms/djangoapps/edxnotes/exceptions.py b/lms/djangoapps/edxnotes/exceptions.py index 4e02362ea327..25ada954a293 100644 --- a/lms/djangoapps/edxnotes/exceptions.py +++ b/lms/djangoapps/edxnotes/exceptions.py @@ -8,3 +8,10 @@ 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 index 95440c9d87f1..37c5099e5eb8 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -2,8 +2,9 @@ Helper methods related to EdxNotes. """ import json -import requests import logging +import requests +from requests.exceptions import RequestException from uuid import uuid4 from json import JSONEncoder from datetime import datetime @@ -21,7 +22,7 @@ from provider.oauth2.models import AccessToken, Client import oauth2_provider.oidc as oidc from provider.utils import now -from .exceptions import EdxNotesParseError +from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable log = logging.getLogger(__name__) @@ -84,13 +85,16 @@ def send_request(user, course_id, path="", query_string=""): "text": query_string, }) - response = requests.get( - url, - headers={ - "x-annotator-auth-token": get_id_token(user) - }, - params=params - ) + 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 @@ -131,13 +135,12 @@ 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. Try again in a few minutes.")) + raise EdxNotesParseError(_("Server error. Please try again in a few minutes.")) content.update({ "rows": preprocess_collection(user, course, collection) @@ -151,7 +154,6 @@ 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: diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index e0dec9121a6a..83df677aae4e 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -22,7 +22,7 @@ from courseware.module_render import get_module_for_descriptor from student.tests.factories import UserFactory -from .exceptions import EdxNotesParseError +from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from . import helpers @@ -550,6 +550,17 @@ def test_edxnotes_view_is_disabled(self): 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): @@ -581,6 +592,18 @@ def test_search_notes_is_disabled(self, mock_search): 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): diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 608672788d6f..3db34a0fcde8 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -13,7 +13,7 @@ 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 +from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from edxnotes.helpers import ( get_notes, get_id_token, @@ -35,7 +35,11 @@ def edxnotes(request, course_id): if not is_feature_enabled(course): raise Http404 - notes = get_notes(request.user, course) + try: + notes = get_notes(request.user, course) + except EdxNotesServiceUnavailable: + raise Http404 + context = { "course": course, "search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}), @@ -63,7 +67,7 @@ def search_notes(request, course_id): query_string = request.GET["text"] try: search_results = search(request.user, course, query_string) - except EdxNotesParseError as err: + except (EdxNotesParseError, EdxNotesServiceUnavailable) as err: return JsonResponseBadRequest({"error": err.message}, status=500) return HttpResponse(search_results) From 90240f1fa78210b1284c25e7c676bc8e00524392 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 8 Dec 2014 13:13:34 +0200 Subject: [PATCH 08/47] TNL-932: Add styling to Notes page. --- cms/djangoapps/contentstore/views/course.py | 4 +- common/djangoapps/terrain/stubs/edxnotes.py | 2 +- common/test/acceptance/pages/lms/edxnotes.py | 36 +-- .../acceptance/tests/lms/test_lms_edxnotes.py | 22 +- lms/djangoapps/edxnotes/helpers.py | 40 +++ lms/djangoapps/edxnotes/tests.py | 96 ++++++- lms/djangoapps/edxnotes/views.py | 15 +- lms/static/js/edxnotes/models/note.js | 29 +- lms/static/js/edxnotes/models/tab.js | 3 +- lms/static/js/edxnotes/views/note_item.js | 54 ++++ lms/static/js/edxnotes/views/notes_page.js | 2 +- lms/static/js/edxnotes/views/page_factory.js | 2 +- lms/static/js/edxnotes/views/search_box.js | 2 +- lms/static/js/edxnotes/views/subview.js | 33 --- lms/static/js/edxnotes/views/tab_item.js | 40 ++- lms/static/js/edxnotes/views/tab_view.js | 26 +- .../js/edxnotes/views/tabs/recent_activity.js | 31 +- .../js/edxnotes/views/tabs/search_results.js | 64 +++-- lms/static/js/edxnotes/views/tabs_list.js | 4 +- lms/static/js/fixtures/edxnotes/edxnotes.html | 43 +-- .../js/spec/edxnotes/custom_matchers.js | 10 +- .../js/spec/edxnotes/models/note_spec.js | 48 ++++ .../js/spec/edxnotes/views/note_item_spec.js | 64 +++++ .../js/spec/edxnotes/views/notes_page_spec.js | 23 +- .../js/spec/edxnotes/views/search_box_spec.js | 10 +- .../js/spec/edxnotes/views/tab_item_spec.js | 25 +- .../js/spec/edxnotes/views/tab_view_spec.js | 57 ++-- .../views/tabs/recent_activity_spec.js | 17 +- .../views/tabs/search_results_spec.js | 52 ++-- .../js/spec/edxnotes/views/tabs_list_spec.js | 22 +- lms/static/js/spec/main.js | 4 +- lms/static/require-config-lms.js | 1 + lms/static/sass/_developer.scss | 6 + lms/static/sass/base/_mixins.scss | 17 ++ lms/static/sass/course-rtl.scss.mako | 2 +- lms/static/sass/course.scss.mako | 2 +- lms/static/sass/course/_edxnotes.scss | 174 ------------ lms/static/sass/course/_student-notes.scss | 266 ++++++++++++++++++ lms/templates/edxnotes/edxnotes.html | 88 ++++-- lms/templates/edxnotes/note-item.underscore | 36 +++ .../edxnotes/recent-activity-item.underscore | 28 -- lms/templates/edxnotes/tab-item.underscore | 7 +- 42 files changed, 1017 insertions(+), 490 deletions(-) create mode 100644 lms/static/js/edxnotes/views/note_item.js delete mode 100644 lms/static/js/edxnotes/views/subview.js create mode 100644 lms/static/js/spec/edxnotes/models/note_spec.js create mode 100644 lms/static/js/spec/edxnotes/views/note_item_spec.js delete mode 100644 lms/static/sass/course/_edxnotes.scss create mode 100644 lms/static/sass/course/_student-notes.scss create mode 100644 lms/templates/edxnotes/note-item.underscore delete mode 100644 lms/templates/edxnotes/recent-activity-item.underscore diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 38a75e868561..35b0ea076d39 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -945,7 +945,7 @@ def _config_course_advanced_components(request, course_module): return filter_tabs - # pylint: disable=invalid-name +# pylint: disable=invalid-name def _config_course_settings(request, course_module, filter_tabs=True): """ Check to see if the user enabled some advanced settings (boolean). @@ -957,7 +957,7 @@ def _config_course_settings(request, course_module, filter_tabs=True): tab_component_map = { 'edxnotes': ['edxnotes'] } - # Check to see if the user instantiated any notes or open ended components + # Check to see if the user instantiated any notes or open ended components for tab_type in tab_component_map.keys(): if tab_type in request.json: component_types = tab_component_map.get(tab_type) diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py index d1e82aa2dd95..3e697f51e958 100644 --- a/common/djangoapps/terrain/stubs/edxnotes.py +++ b/common/djangoapps/terrain/stubs/edxnotes.py @@ -327,4 +327,4 @@ 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")] + return [note for note in data if unicode(query).strip() in note.get("text", "").split()] diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index 941f79c88c0b..5275e3d06d70 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -32,9 +32,9 @@ class EdxNotesPageView(PageObject): Base class for EdxNotes views: Recent Activity, Course Structure, Search Results. """ url = None - BODY_SELECTOR = ".edx-notes-page-items-list" - TAB_SELECTOR = ".tab-item" - CHILD_SELECTOR = ".edx-notes-page-item" + BODY_SELECTOR = ".tab-panel" + TAB_SELECTOR = ".tab" + CHILD_SELECTOR = ".note" @unguarded def visit(self): @@ -86,16 +86,16 @@ class RecentActivityView(EdxNotesPageView): """ Helper class for Recent Activity view. """ - BODY_SELECTOR = "#edx-notes-page-recent-activity" - TAB_SELECTOR = ".tab-item.tab-recent-activity" + BODY_SELECTOR = "#recent-panel" + TAB_SELECTOR = ".tab#view-recent-activity" class SearchResultsView(EdxNotesPageView): """ Helper class for Search Results view. """ - BODY_SELECTOR = "#edx-notes-page-search-results" - TAB_SELECTOR = ".tab-item.tab-search-results" + BODY_SELECTOR = "#search-results-panel" + TAB_SELECTOR = ".tab#view-search-results" class EdxNotesPage(CoursePage): @@ -113,7 +113,7 @@ def __init__(self, *args, **kwargs): self.current_view = self.MAPPING["recent"](self.browser) def is_browser_on_page(self): - return self.q(css=".edx-notes-page-wrapper").present + return self.q(css=".wrapper-student-notes").present def switch_to_tab(self, tab_name): """ @@ -133,8 +133,8 @@ def search(self, text): """ Runs search with `text(str)` query. """ - self.q(css=".search-box #search-field").first.fill(text) - self.q(css='.search-box button').first.click() + 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) @@ -144,7 +144,7 @@ def tabs(self): """ Returns all tabs on the page. """ - tabs = self.q(css=".tabs .tab-item-name") + tabs = self.q(css=".tabs .tab-label") if tabs: return tabs.text else: @@ -180,7 +180,7 @@ def no_content_text(self): """ Returns no content message. """ - element = self.q(css=".no-content").first + element = self.q(css=".is-empty").first if element: return element.text[0] else: @@ -191,8 +191,8 @@ class EdxNotesPageItem(NoteChild): """ Helper class that works with note items on Note page of the course. """ - BODY_SELECTOR = ".edx-notes-page-item" - UNIT_LINK_SELECTOR = "a.edx-notes-item-unit-link" + BODY_SELECTOR = ".note" + UNIT_LINK_SELECTOR = "a.reference-unit-link" def _get_element_text(self, selector): element = self.q(css=self._bounded_selector(selector)).first @@ -212,19 +212,19 @@ def unit_name(self): @property def text(self): - return self._get_element_text(".edx-notes-item-text") + return self._get_element_text(".note-comments") @property def quote(self): - return self._get_element_text(".edx-notes-item-quote") + return self._get_element_text(".note-excerpt") @property def time_updated(self): - return self._get_element_text(".edx-notes-item-last-edited-value") + return self._get_element_text(".reference-updated-date") @property def title_highlighted(self): - return self._get_element_text(".edx-notes-item-highlight-title") + return self._get_element_text(".reference-title") class EdxNotesUnitPage(CoursePage): diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 9dfbaaacd93e..c6cfe39c477a 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -305,10 +305,10 @@ def _add_default_notes(self): usage_id=xblocks[0].locator, user=self.username, course_id=self.course_fixture._course_key, - text="Annotate this text!", + text="Annotate this text", quote="Second note", updated=datetime(2013, 1, 1, 1, 1, 1, 1).isoformat(), - ranges=[Range(startOffset=0, endOffset=19)], + ranges=[Range(startOffset=0, endOffset=18)], ), Note( usage_id=xblocks[1].locator, @@ -328,7 +328,7 @@ def test_no_content(self): Then I see only "You do not have any notes within the course." message """ self.notes_page.visit() - self.assertEqual("You do not have any notes within the course.", self.notes_page.no_content_text) + self.assertIn("YOU HAVE NOT MADE ANY NOTES IN THIS COURSE YET.", self.notes_page.no_content_text) def test_recent_activity_view(self): """ @@ -341,10 +341,16 @@ def test_recent_activity_view(self): self._add_default_notes() def assertContent(item, text=None, quote=None, unit_name=None, time_updated=None): - self.assertEqual(item.text, text) - self.assertEqual(item.quote, quote) - self.assertEqual(item.unit_name, unit_name) - self.assertEqual(item.time_updated, time_updated) + 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) if text is not None and quote is not None: self.assertEqual(item.title_highlighted, "HIGHLIGHTED & NOTED IN:") elif text is not None: @@ -364,7 +370,7 @@ def assertContent(item, text=None, quote=None, unit_name=None, time_updated=None assertContent( items[1], - text="Annotate this text!", + text="Annotate this text", quote=u"Second note", unit_name="Test Unit 1", time_updated="Jan 01, 2013 at 01:01 UTC" diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 37c5099e5eb8..834a4d83090f 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -3,12 +3,14 @@ """ import json import logging +import markupsafe 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 @@ -122,6 +124,8 @@ def preprocess_collection(user, course, collection): continue model.update({ + u"text": markupsafe.escape(model["text"]), + u"quote": markupsafe.escape(model["quote"]), u"unit": get_ancestor_context(course, store, usage_key), u"updated": dateutil_parse(model["updated"]), }) @@ -223,6 +227,42 @@ def get_endpoint(path=""): 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': course_module.id.to_deprecated_string()} + 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. diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 83df677aae4e..8d398505a67b 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -7,7 +7,9 @@ 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 @@ -22,9 +24,6 @@ from courseware.module_render import get_module_for_descriptor from student.tests.factories import UserFactory -from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable -from . import helpers - def enable_edxnotes_for_the_course(course, user_id): """ @@ -342,6 +341,31 @@ def test_search_empty_collection(self, mock_get): 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"unit": { + u"url": self._get_jump_to_url(self.vertical), + u"display_name": self.vertical.display_name_with_default, + }, + 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. @@ -507,6 +531,70 @@ def test_send_request_without_query_string(self, mock_get, mock_get_id_token, mo } ) + 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() + mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) + mock_course_module.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() + mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) + mock_course_module.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() + mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) + mock_course_module.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), + }) + @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") class EdxNotesViewsTest(TestCase): @@ -540,7 +628,7 @@ def test_edxnotes_view_is_enabled(self, mock_get_notes): """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_page_url) - self.assertContains(response, "

    Notes

    ") + self.assertContains(response, '

    Notes') @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_view_is_disabled(self): diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 3db34a0fcde8..b43c20be0089 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -18,7 +18,8 @@ get_notes, get_id_token, is_feature_enabled, - search + search, + get_course_position, ) log = logging.getLogger(__name__) @@ -45,8 +46,18 @@ def edxnotes(request, course_id): "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([course], course_key, request.user) + 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) @@ -61,7 +72,7 @@ def search_notes(request, course_id): if not is_feature_enabled(course): raise Http404 - if not "text" in request.GET: + if "text" not in request.GET: return HttpResponseBadRequest() query_string = request.GET["text"] diff --git a/lms/static/js/edxnotes/models/note.js b/lms/static/js/edxnotes/models/note.js index 45b2d9d49957..9ebabcb7314f 100644 --- a/lms/static/js/edxnotes/models/note.js +++ b/lms/static/js/edxnotes/models/note.js @@ -1,6 +1,6 @@ ;(function (define) { 'use strict'; -define(['backbone'], function (Backbone) { +define(['backbone', 'underscore.string'], function (Backbone) { var NoteModel = Backbone.Model.extend({ defaults: { 'id': null, @@ -10,13 +10,36 @@ define(['backbone'], function (Backbone) { 'usage_id': null, 'course_id': null, 'text': null, - 'quote': null, + 'quote': '', 'unit': { 'display_name': null, 'url': null }, - 'ranges': [] + 'ranges': [], + // 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; diff --git a/lms/static/js/edxnotes/models/tab.js b/lms/static/js/edxnotes/models/tab.js index 381800b208ca..07882cd70240 100644 --- a/lms/static/js/edxnotes/models/tab.js +++ b/lms/static/js/edxnotes/models/tab.js @@ -3,8 +3,9 @@ define(['backbone'], function (Backbone) { var TabModel = Backbone.Model.extend({ defaults: { + 'identifier': '', 'name': '', - 'class_name': '', + 'icon': '', 'is_active': false, 'is_closable': false }, 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 000000000000..776d49903acb --- /dev/null +++ b/lms/static/js/edxnotes/views/note_item.js @@ -0,0 +1,54 @@ +;(function (define, undefined) { +'use strict'; +define([ + 'jquery', 'backbone' +], function ($, Backbone) { + var NoteItemView = Backbone.View.extend({ + tagName: 'article', + id: function () { + return 'note-' + _.uniqueId(); + }, + className: 'note', + events: { + 'click .note-excerpt-more-link': 'moreHandler' + }, + + initialize: function (options) { + var templateSelector = '#note-item-tpl', + templateText = $(templateSelector).text(); + + if (!templateText) { + console.error('Failed to load note-item template'); + } + + this.template = _.template(templateText); + 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({}, this.model.attributes, { + message: this.model.getNoteText() + }); + }, + + toggleNote: function () { + var value = !this.model.get('is_expanded'); + this.model.set('is_expanded', value); + }, + + moreHandler: function (event) { + event.preventDefault(); + this.toggleNote(); + } + }); + + return NoteItemView; +}); +}).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 index 1c92bfd0f563..893721bdae7c 100644 --- a/lms/static/js/edxnotes/views/notes_page.js +++ b/lms/static/js/edxnotes/views/notes_page.js @@ -26,7 +26,7 @@ define([ }); this.tabsView = new TabsListView({collection: this.tabsCollection}); - this.$('.edx-notes-page-views') + this.$('.tab-list') .append(this.tabsView.render().$el) .removeClass('is-hidden'); } diff --git a/lms/static/js/edxnotes/views/page_factory.js b/lms/static/js/edxnotes/views/page_factory.js index adba038d327f..156dea33d656 100644 --- a/lms/static/js/edxnotes/views/page_factory.js +++ b/lms/static/js/edxnotes/views/page_factory.js @@ -17,7 +17,7 @@ define([ var collection = new NotesCollection(params.notesList); return new NotesPageView({ - el: $('.edx-notes-page-wrapper').get(0), + el: $('.wrapper-student-notes').get(0), collection: collection, debug: params.debugMode, user: params.user, diff --git a/lms/static/js/edxnotes/views/search_box.js b/lms/static/js/edxnotes/views/search_box.js index 58de5be7cbe2..0bdd893099cd 100644 --- a/lms/static/js/edxnotes/views/search_box.js +++ b/lms/static/js/edxnotes/views/search_box.js @@ -53,7 +53,7 @@ define([ * @return {String} */ getSearchQuery: function () { - return this.$el.find('#search-field').val(); + return this.$el.find('#search-notes-input').val(); }, /** diff --git a/lms/static/js/edxnotes/views/subview.js b/lms/static/js/edxnotes/views/subview.js deleted file mode 100644 index 89e12634c1e8..000000000000 --- a/lms/static/js/edxnotes/views/subview.js +++ /dev/null @@ -1,33 +0,0 @@ -;(function (define, undefined) { -'use strict'; -define(['underscore', 'backbone'], -function (_, Backbone) { - var SubView = Backbone.View.extend({ - className: 'edx-notes-page-items-list', - templateName: '', - initialize: function (options) { - this.options = options; - if (this.templateName) { - this.template = this.loadTemplate(this.templateName); - } - }, - - render: function () { - return this; - }, - - loadTemplate: function (name) { - var templateSelector = "#" + name + "-tpl", - templateText = $(templateSelector).text(); - - if (!templateText) { - console.error("Failed to load " + name + " template"); - } - - return _.template(templateText); - } - }); - - return SubView; -}); -}).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 index 55c7f308aa85..a8368bd1c7cb 100644 --- a/lms/static/js/edxnotes/views/tab_item.js +++ b/lms/static/js/edxnotes/views/tab_item.js @@ -4,7 +4,7 @@ define(['gettext', 'underscore', 'backbone'], function (gettext, _, Backbone) { var TabItemView = Backbone.View.extend({ tagName: 'li', - className: 'tab-item', + className: 'tab', activeClassName: 'is-active', events: { @@ -14,45 +14,39 @@ function (gettext, _, Backbone) { }, initialize: function (options) { - this.template = _.template($('#tab-item-tpl').text()); - this.options = options; - this.$el.addClass(this.model.get('class_name')); - this.bindEvents(); - }, + var templateSelector = '#tab-item-tpl', + templateText = $(templateSelector).text(); - render: function () { - var html = this.template(this.model.toJSON()); - this.$el.html(html); - return this; - }, + if (!templateText) { + console.error('Failed to load tab-item template'); + } - bindEvents: function () { - this.model.on({ + this.template = _.template(templateText); + this.$el.attr('id', this.model.get('identifier')); + this.listenTo(this.model, { 'change:is_active': function (model, value) { this.$el.toggleClass(this.activeClassName, value); }, 'destroy': this.remove - }, this); + }); + }, + + 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.select(); + this.model.activate(); } }, closeHandler: function (event) { event.preventDefault(); event.stopPropagation(); - this.close(); - }, - - select: function () { - this.model.activate(); - }, - - close: function () { this.model.destroy(); } }); diff --git a/lms/static/js/edxnotes/views/tab_view.js b/lms/static/js/edxnotes/views/tab_view.js index 59fdd6879ae3..c3a837d07b83 100644 --- a/lms/static/js/edxnotes/views/tab_view.js +++ b/lms/static/js/edxnotes/views/tab_view.js @@ -28,7 +28,7 @@ define([ createTab: function () { this.tabModel = new TabModel(this.tabInfo); this.options.tabsCollection.add(this.tabModel); - this.tabModel.on({ + this.listenTo(this.tabModel, { 'change:is_active': function (model, value) { if (value) { this.render(); @@ -41,7 +41,7 @@ define([ this.tabModel = null; this.onClose(); } - }, this); + }); }, /** @@ -56,8 +56,8 @@ define([ }, renderContent: function () { - var contentView = this.getSubView(); - this.$('.course-info').append(contentView.render().$el); + this.contentView = this.getSubView(); + this.$('.wrapper-tabs').append(this.contentView.render().$el); return $.Deferred().resolve().promise(); }, @@ -67,7 +67,9 @@ define([ }, destroySubView: function () { - this.$('.edx-notes-page-items-list').remove(); + if (this.contentView) { + this.contentView.remove(); + } }, /** @@ -83,18 +85,25 @@ define([ */ onClose: function () { }, + /** + * Returns the page's loading indicator. + */ + getLoadingIndicator: function() { + return this.$('.ui-loading'); + }, + /** * Shows the page's loading indicator. */ showLoadingIndicator: function() { - this.$('.ui-loading').removeClass('is-hidden'); + this.getLoadingIndicator().removeClass('is-hidden'); }, /** * Hides the page's loading indicator. */ hideLoadingIndicator: function() { - this.$('.ui-loading').addClass('is-hidden'); + this.getLoadingIndicator().addClass('is-hidden'); }, @@ -104,7 +113,8 @@ define([ showErrorMessage: function (message) { this.$('.inline-error') .text(message) - .removeClass('is-hidden'); + .removeClass('is-hidden') + .focus(); }, /** diff --git a/lms/static/js/edxnotes/views/tabs/recent_activity.js b/lms/static/js/edxnotes/views/tabs/recent_activity.js index 4d3740887194..2289330bcb17 100644 --- a/lms/static/js/edxnotes/views/tabs/recent_activity.js +++ b/lms/static/js/edxnotes/views/tabs/recent_activity.js @@ -1,22 +1,37 @@ ;(function (define, undefined) { 'use strict'; define([ - 'gettext', 'js/edxnotes/views/subview', 'js/edxnotes/views/tab_view' -], function (gettext, SubView, TabView) { + 'gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item', + 'js/edxnotes/views/tab_view', 'underscore.string' +], function (gettext, _, Backbone, NoteItemView, TabView) { var RecentActivityView = TabView.extend({ - SubViewConstructor: SubView.extend({ - id: 'edx-notes-page-recent-activity', - templateName: 'recent-activity-item', + SubViewConstructor: Backbone.View.extend({ + tagName: 'section', + className: 'tab-panel', + id: 'recent-panel', render: function () { - this.$el.html(this.template({collection: this.collection})); - + var container = document.createDocumentFragment(); + container.appendChild(this.getTitle()); + this.collection.each(function (model) { + var item = new NoteItemView({model: model}); + container.appendChild(item.render().el); + }); + this.$el.html(container); return this; + }, + + getTitle: function () { + return $('

    ', { + 'class': 'sr', + 'text': gettext('Recent Activity') + }).get(0); } }), tabInfo: { + identifier: 'view-recent-activity', name: gettext('Recent Activity'), - class_name: 'tab-recent-activity' + icon: 'icon-time' } }); diff --git a/lms/static/js/edxnotes/views/tabs/search_results.js b/lms/static/js/edxnotes/views/tabs/search_results.js index 040900d908f8..00142e66aece 100644 --- a/lms/static/js/edxnotes/views/tabs/search_results.js +++ b/lms/static/js/edxnotes/views/tabs/search_results.js @@ -1,30 +1,53 @@ ;(function (define, undefined) { 'use strict'; define([ - 'gettext', 'js/edxnotes/views/subview', 'js/edxnotes/views/tab_view', - 'js/edxnotes/views/search_box', 'jquery.highlight' -], function (gettext, SubView, TabView, SearchBoxView) { + 'gettext', 'backbone', 'js/edxnotes/views/note_item', + 'js/edxnotes/views/tab_view', 'js/edxnotes/views/search_box', 'jquery.highlight' +], function (gettext, Backbone, NoteItemView, TabView, SearchBoxView) { var SearchResultsView = TabView.extend({ - SubViewConstructor: SubView.extend({ - id: 'edx-notes-page-search-results', + SubViewConstructor: Backbone.View.extend({ + tagName: 'section', + className: 'tab-panel', + id: 'search-results-panel', + attributes: { + 'tabindex': -1 + }, highlightMatchedText: true, - templateName: 'recent-activity-item', render: function () { - this.$el.html(this.template({collection: this.collection})); + var container = document.createDocumentFragment(); + container.appendChild(this.getTitle()); + this.collection.each(function (model) { + var item = new NoteItemView({model: model}); + container.appendChild(item.render().el); + }); + this.$el.html(container); if (this.highlightMatchedText) { - this.$('.edx-notes-item-text').highlight(this.options.searchQuery, { + this.$('.note-comment-p').highlight(this.options.searchQuery, { element: 'span', - className: 'edx-notes-highlight', - caseSensitive: false + className: 'note-highlight', + caseSensitive: false, + wordsOnly: false }); } return this; + }, + + getTitle: function () { + return $('

    ', { + 'class': 'sr', + 'text': gettext('Search Results') + }).get(0); } }), - NoResultsViewConstructor: SubView.extend({ - id: 'edx-notes-page-no-search-results', + NoResultsViewConstructor: Backbone.View.extend({ + tagName: 'section', + className: 'tab-panel', + id: 'no-results-panel', + attributes: { + 'tabindex': -1 + }, render: function () { var message = gettext('No results found for "%(query_string)s".'); this.$el.html(interpolate(message, { @@ -35,8 +58,9 @@ define([ }), tabInfo: { + identifier: 'view-search-results', name: gettext('Search Results'), - class_name: 'tab-search-results', + icon: 'icon-search', is_closable: true }, @@ -45,7 +69,7 @@ define([ TabView.prototype.initialize.call(this, options); this.searchResults = null; this.searchBox = new SearchBoxView({ - el: this.$('form.search-box').get(0), + el: document.getElementById('search-notes-form'), user: this.options.user, courseId: this.options.courseId, debug: this.options.debug, @@ -56,10 +80,11 @@ define([ }, renderContent: function () { + this.getLoadingIndicator().focus(); return this.searchPromise.done(_.bind(function () { - var contentView = this.getSubView(); - if (contentView) { - this.$('.course-info').append(contentView.render().$el); + this.contentView = this.getSubView(); + if (this.contentView) { + this.$('.wrapper-tabs').append(this.contentView.render().$el); } }, this)); }, @@ -117,9 +142,14 @@ define([ total: total, searchQuery: searchQuery }; + if (this.searchDeferred) { this.searchDeferred.resolve(); } + + if (this.contentView) { + this.contentView.$el.focus(); + } }, onSearchError: function (errorMessage) { diff --git a/lms/static/js/edxnotes/views/tabs_list.js b/lms/static/js/edxnotes/views/tabs_list.js index a6397bd80f03..52d246dfbecc 100644 --- a/lms/static/js/edxnotes/views/tabs_list.js +++ b/lms/static/js/edxnotes/views/tabs_list.js @@ -9,14 +9,14 @@ define([ initialize: function (options) { this.options = options; - this.collection.on({ + this.listenTo(this.collection, { 'add': this.createTab, 'destroy': function (model, collection) { if (model.isActive() && collection.length) { collection.at(0).activate(); } } - }, this); + }); }, render: function () { diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html index f4ee0678000d..6701d54d64d5 100644 --- a/lms/static/js/fixtures/edxnotes/edxnotes.html +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -1,23 +1,28 @@ -
    -
    -
    -

    Notes

    - Highlights and personal notes you've made within the course +
    +
    +
    +

    + Notes + Highlights and personal notes you've made within the course +

    - -
    - -
    -
    - -
    -

    Loading...

    -
    +
    + +
    + + +
    +

    Loading...

    +
    + +
    diff --git a/lms/static/js/spec/edxnotes/custom_matchers.js b/lms/static/js/spec/edxnotes/custom_matchers.js index d3d645d6b9f0..6521198768d3 100644 --- a/lms/static/js/spec/edxnotes/custom_matchers.js +++ b/lms/static/js/spec/edxnotes/custom_matchers.js @@ -6,9 +6,9 @@ define(['jquery'], function($) { var trimmedText = $.trim($(this.actual).text()); if (text && $.isFunction(text.test)) { - return text.test(trimmedText); + return text.test(trimmedText); } else { - return trimmedText.indexOf(text) !== -1; + return trimmedText.indexOf(text) !== -1; } }, @@ -22,7 +22,11 @@ define(['jquery'], function($) { 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/models/note_spec.js b/lms/static/js/spec/edxnotes/models/note_spec.js new file mode 100644 index 000000000000..85063773a6ca --- /dev/null +++ b/lms/static/js/spec/edxnotes/models/note_spec.js @@ -0,0 +1,48 @@ +define(['js/edxnotes/collections/notes'], function(NotesCollection) { + 'use strict'; + describe('EdxNotes NoteModel', function() { + var 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.', + 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...', + SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt'; + + beforeEach(function () { + this.collection = new NotesCollection([ + {quote: LONG_TEXT}, + {quote: 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(TRUNCATED_TEXT); + model.set('is_expanded', true); + // is_expanded = true, show_link = true + expect(model.getNoteText()).toBe(LONG_TEXT); + model.set('show_link', false); + model.set('is_expanded', false); + // is_expanded = false, show_link = false + expect(model.getNoteText()).toBe(LONG_TEXT); + }); + }); +}); 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 000000000000..b4196e7d3f5a --- /dev/null +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -0,0 +1,64 @@ +define([ + 'jquery', 'underscore', 'js/common_helpers/template_helpers', + 'js/edxnotes/models/note', 'js/edxnotes/views/note_item', + 'js/spec/edxnotes/custom_matchers' +], function($, _, TemplateHelpers, NoteModel, NoteItemView, customMatchers) { + 'use strict'; + describe('EdxNotes NoteItemView', function() { + var 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.', + 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...', + SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt', + getView; + + getView = function (model) { + model = new NoteModel(_.defaults(model || {}, { + created: 'December 11, 2014 at 11:12AM', + updated: 'December 11, 2014 at 11:12AM', + text: 'Third added model', + quote: LONG_TEXT + })); + + return new NoteItemView({model: model}).render(); + }; + + beforeEach(function() { + customMatchers(this); + TemplateHelpers.installTemplates([ + 'templates/edxnotes/note-item' + ]); + }); + + it('can be rendered properly', function() { + var view = getView(); + expect(view.$el).toContain('.note-excerpt-more-link'); + expect(view.$el).toContainText(TRUNCATED_TEXT); + expect(view.$el).toContainText('More'); + view.$('.note-excerpt-more-link').click(); + + expect(view.$el).toContainText(LONG_TEXT); + expect(view.$el).toContainText('(Show less)'); + + view = getView({quote: SHORT_TEXT}); + expect(view.$el).not.toContain('.note-excerpt-more-link'); + expect(view.$el).toContainText(SHORT_TEXT); + }); + + 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'); + }); + }); +}); diff --git a/lms/static/js/spec/edxnotes/views/notes_page_spec.js b/lms/static/js/spec/edxnotes/views/notes_page_spec.js index c9953f857551..261eebe9dcbd 100644 --- a/lms/static/js/spec/edxnotes/views/notes_page_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_page_spec.js @@ -30,7 +30,7 @@ define([ customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ - 'templates/edxnotes/recent-activity-item', + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' ]); this.view = new NotesFactory({notesList: notes}); @@ -40,25 +40,18 @@ define([ it('should be displayed properly', function() { var requests = AjaxHelpers.requests(this); - expect(this.view.$('.tab-search-results')).not.toExist(); - expect(this.view.$('.tab-recent-activity')).toHaveClass('is-active'); - expect(this.view.$('.edx-notes-page-items-list')).toExist(); + expect(this.view.$('#view-search-results')).not.toExist(); + expect(this.view.$('#view-recent-activity')).toHaveClass('is-active'); + expect(this.view.$('.tab-panel')).toExist(); - this.view.$('.search-box input').val('test_query'); - this.view.$('.search-box button[type=submit]').click(); + this.view.$('.search-notes-input').val('test_query'); + this.view.$('.search-notes-submit').click(); AjaxHelpers.respondWithJson(requests, { total: 0, rows: [] }); - expect(this.view.$('.tab-search-results')).toHaveClass('is-active'); - expect(this.view.$('.tab-recent-activity')).toExist(); - }); - - it('should display update value and accompanying text', function() { - _.each($('.edxnotes-page-item'), function(element, index) { - expect($('dl > dt', element).last()).toContainText('Last Edited:'); - expect($('dl > dd', element).last()).toContainText(notes[index].updated); - }); + expect(this.view.$('#view-search-results')).toHaveClass('is-active'); + expect(this.view.$('#view-recent-activity')).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 index 3aebda632c42..912027e23bc5 100644 --- a/lms/static/js/spec/edxnotes/views/search_box_spec.js +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -8,7 +8,7 @@ define([ getSearchBox = function (options) { options = _.defaults(options || {}, { - el: $('form.search-box').get(0), + el: $('#search-notes-form').get(0), user: 'test_user', courseId: 'test_course_id', beforeSearchStart: jasmine.createSpy(), @@ -21,19 +21,19 @@ define([ }; submitForm = function (searchBox, text) { - searchBox.$('input').val(text); - searchBox.$('button[type=submit]').click(); + searchBox.$('.search-notes-input').val(text); + searchBox.$('.search-notes-submit').click(); }; assertBoxIsEnabled = function (searchBox) { expect(searchBox.$el).not.toHaveClass('is-looking'); - expect(searchBox.$('button[type=submit]')).not.toHaveClass('is-disabled'); + expect(searchBox.$('.search-notes-submit')).not.toHaveClass('is-disabled'); expect(searchBox.isDisabled).toBeFalsy(); }; assertBoxIsDisabled = function (searchBox) { expect(searchBox.$el).toHaveClass('is-looking'); - expect(searchBox.$('button[type=submit]')).toHaveClass('is-disabled'); + expect(searchBox.$('.search-notes-submit')).toHaveClass('is-disabled'); expect(searchBox.isDisabled).toBeTruthy(); }; diff --git a/lms/static/js/spec/edxnotes/views/tab_item_spec.js b/lms/static/js/spec/edxnotes/views/tab_item_spec.js index ab1f80d316c1..cd6c1809bfb4 100644 --- a/lms/static/js/spec/edxnotes/views/tab_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_item_spec.js @@ -8,10 +8,11 @@ define([ customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); this.collection = new TabsCollection([ - {'class_name': 'first-item'}, + {identifier: 'first-item'}, { - 'class_name': 'second-item', - 'is_closable': true + identifier: 'second-item', + is_closable: true, + icon: 'icon-class' } ]); this.tabsList = new TabsListView({ @@ -19,9 +20,17 @@ define([ }).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'); + var firstItem = this.tabsList.$('#first-item'), + secondItem = this.tabsList.$('#second-item'); expect(firstItem).toHaveClass('is-active'); // first tab is active expect(secondItem).not.toHaveClass('is-active'); // second tab is not active @@ -31,11 +40,11 @@ define([ }); it('can close the tab', function () { - var secondItem = this.tabsList.$('.second-item'); + var secondItem = this.tabsList.$('#second-item'); - expect(this.tabsList.$('.tab-item')).toHaveLength(2); + expect(this.tabsList.$('.tab')).toHaveLength(2); secondItem.find('.btn-close').click(); - expect(this.tabsList.$('.tab-item')).toHaveLength(1); + 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 index e830e301361f..c2a73b642352 100644 --- a/lms/static/js/spec/edxnotes/views/tab_view_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -1,14 +1,15 @@ define([ - 'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs_list', 'js/edxnotes/views/subview', - 'js/edxnotes/views/tab_view', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' + '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( - $, TemplateHelpers, TabsCollection, TabsListView, SubView, TabView, customMatchers + $, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView, customMatchers ) { 'use strict'; describe('EdxNotes TabView', function() { - var TestSubView = SubView.extend({ - id: 'edx-notes-page-test-subview', + var TestSubView = Backbone.View.extend({ + id: 'test-subview-panel', + className: 'tab-panel', content: '

    test view content

    ', render: function () { this.$el.html(this.content); @@ -26,7 +27,7 @@ define([ getView = function (tabsCollection, options) { var view; options = _.defaults(options || {}, { - el: $('.edx-notes-page-wrapper'), + el: $('.wrapper-student-notes'), collection: [], tabsCollection: tabsCollection }); @@ -43,18 +44,18 @@ define([ customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ - 'templates/edxnotes/recent-activity-item', 'templates/edxnotes/tab-item' + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' ]); this.tabsCollection = new TabsCollection(); this.tabsList = new TabsListView({collection: this.tabsCollection}).render(); - this.tabsList.$el.appendTo($('.edx-notes-page-wrapper')); + 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-item')).toExist(); - expect(view.$('.course-info')).toContainHtml('

    test view content

    '); + 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 () { @@ -62,24 +63,24 @@ define([ createTabOnInitialization: false }); expect(this.tabsCollection).toHaveLength(0); - expect(view.$('.tab-item')).not.toExist(); - expect(view.$('.course-info')).not.toContainHtml('

    test view content

    '); + 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({'class_name': 'second-tab'}); - view.$('.tab-item.second-tab').click(); - expect(view.$('.tab-item')).toHaveLength(2); - expect(view.$('.course-info')).not.toContainHtml('

    test view content

    '); + 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-item .btn-close').click(); - expect(view.$('.tab-item')).toHaveLength(0); - expect(view.$('.course-info')).not.toContainHtml('

    test view content

    '); + view.$('.tab .btn-close').click(); + expect(view.$('.tab')).toHaveLength(0); + expect(view.$('.wrapper-tabs')).not.toContainHtml('

    test view content

    '); expect(view.tabModel).toBeNull(); expect(view.onClose).toHaveBeenCalled(); }); @@ -88,19 +89,21 @@ define([ var view = getView(this.tabsCollection); TestSubView.prototype.content = '

    New content

    '; view.render(); - expect(view.$('.course-info')).toContainHtml('

    New content

    '); - expect(view.$('.course-info')).not.toContainHtml('

    test view content

    '); + 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); + var view = getView(this.tabsCollection), + errorHolder = view.$('.inline-error'); view.showErrorMessage('

    error message is here

    '); - expect(view.$('.inline-error')).not.toHaveClass('is-hidden'); - expect(view.$('.inline-error')).toContainText('

    error message is here

    '); + expect(errorHolder).not.toHaveClass('is-hidden'); + expect(errorHolder).toBeFocused(); + expect(errorHolder).toContainText('

    error message is here

    '); view.hideErrorMessage(); - expect(view.$('.inline-error')).toHaveClass('is-hidden'); - expect(view.$('.inline-error')).toBeEmpty(); + expect(errorHolder).toHaveClass('is-hidden'); + expect(errorHolder).toBeEmpty(); }); }); }); 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 index be5ada7e87ea..c1fdd7a34644 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js @@ -32,7 +32,7 @@ define([ var view; options = _.defaults(options || {}, { - el: $('.edx-notes-page-wrapper'), + el: $('.wrapper-student-notes'), collection: collection, tabsCollection: tabsCollection, }); @@ -47,7 +47,7 @@ define([ customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ - 'templates/edxnotes/recent-activity-item', 'templates/edxnotes/tab-item' + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' ]); this.collection = new NotesCollection(notes); @@ -60,15 +60,16 @@ define([ expect(this.tabsCollection).toHaveLength(1); expect(this.tabsCollection.at(0).attributes).toEqual({ name: 'Recent Activity', - class_name: 'tab-recent-activity', + identifier: 'view-recent-activity', + icon: 'icon-time', is_active: true, is_closable: false }); - expect(view.$('#edx-notes-page-recent-activity')).toExist(); - expect(view.$('.edx-notes-page-item')).toHaveLength(3); - _.each(view.$('.edx-notes-page-item'), function(element, index) { - expect($('.edx-notes-item-text', element)).toContainText(notes[index].text); - expect($('.edx-notes-item-quote', element)).toContainText(notes[index].quote); + 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 index 04ec0c02baf7..447db5c366f1 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -35,7 +35,7 @@ define([ getView = function (tabsCollection, options) { options = _.defaults(options || {}, { - el: $('.edx-notes-page-wrapper'), + el: $('.wrapper-student-notes'), tabsCollection: tabsCollection, user: 'test_user', courseId: 'course_id', @@ -45,15 +45,15 @@ define([ }; submitForm = function (searchBox, text) { - searchBox.$('input').val(text); - searchBox.$('button[type=submit]').click(); + 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/recent-activity-item', 'templates/edxnotes/tab-item' + 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' ]); this.tabsCollection = new TabsCollection(); @@ -62,29 +62,31 @@ define([ it('does not create a tab and content on initialization', function () { var view = getView(this.tabsCollection); expect(this.tabsCollection).toHaveLength(0); - expect(view.$('#edx-notes-page-search-results')).not.toExist(); + 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, 'econd'); + submitForm(view.searchBox, 'second'); AjaxHelpers.respondWithJson(requests, responseJson); expect(this.tabsCollection).toHaveLength(1); expect(this.tabsCollection.at(0).attributes).toEqual({ name: 'Search Results', - class_name: 'tab-search-results', + identifier: 'view-search-results', + icon: 'icon-search', is_active: true, is_closable: true }); - expect(view.$('#edx-notes-page-search-results')).toExist(); - expect(view.$('.edx-notes-item-text').eq(1)).toContainHtml( - 'econd' + expect(view.$('#search-results-panel')).toExist(); + expect(view.$('#search-results-panel')).toBeFocused(); + expect(view.$('.note-comments').eq(1)).toContainHtml( + 'Second' ); - expect(view.$('.edx-notes-item-quote .edx-notes-highlight')).not.toExist(); - expect(view.$('.edx-notes-page-item')).toHaveLength(3); + expect(view.$('.note-excerpt .note-highlight')).not.toExist(); + expect(view.$('.note')).toHaveLength(3); view.searchResults.collection.each(function (model, index) { expect(model.get('text')).toBe(notes[index].text); }); @@ -96,9 +98,10 @@ define([ 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.$('.edx-notes-page-items-list')).not.toExist(); + expect(view.$('.tab-panel')).not.toExist(); AjaxHelpers.respondWithJson(requests, responseJson); expect(view.$('.ui-loading')).toHaveClass('is-hidden'); }); @@ -113,10 +116,11 @@ define([ rows: [] }); - expect(view.$('#edx-notes-page-search-results')).not.toExist(); - expect(view.$('#edx-notes-page-no-search-results')).toExist(); - expect(view.$('.edx-notes-highlight')).not.toExist(); - expect(view.$('#edx-notes-page-no-search-results')).toContainText( + expect(view.$('#search-results-panel')).not.toExist(); + expect(view.$('#no-results-panel')).toBeFocused(); + expect(view.$('#no-results-panel')).toExist(); + expect(view.$('.note-highlight')).not.toExist(); + expect(view.$('#no-results-panel')).toContainText( 'No results found for "some text".' ); }); @@ -132,12 +136,12 @@ define([ this.tabsCollection.add({}); this.tabsCollection.at(1).activate(); - expect(view.$('#edx-notes-page-search-results')).not.toExist(); + expect(view.$('#search-results-panel')).not.toExist(); this.tabsCollection.at(0).activate(); expect(requests).toHaveLength(1); - expect(view.$('#edx-notes-page-search-results')).toExist(); - expect(view.$('.edx-notes-page-item')).toHaveLength(3); + expect(view.$('#search-results-panel')).toExist(); + expect(view.$('.note')).toHaveLength(3); }); it('can clear search results if tab is closed', function () { @@ -165,7 +169,7 @@ define([ expect(view.$('.inline-error')).not.toHaveClass('is-hidden'); expect(view.$('.inline-error')).toContainText('test error message'); - expect(view.$('.edx-notes-highlight')).not.toExist(); + expect(view.$('.note-highlight')).not.toExist(); expect(view.$('.ui-loading')).toHaveClass('is-hidden'); submitForm(view.searchBox, 'Second'); @@ -173,7 +177,7 @@ define([ expect(view.$('.inline-error')).toHaveClass('is-hidden'); expect(view.$('.inline-error')).toBeEmpty(); - expect(view.$('.edx-notes-highlight')).toExist(); + expect(view.$('.note-highlight')).toExist(); }); it('can correctly update search results', function () { @@ -189,7 +193,7 @@ define([ submitForm(view.searchBox, 'test_query'); AjaxHelpers.respondWithJson(requests, responseJson); - expect(view.$('.edx-notes-page-item')).toHaveLength(3); + expect(view.$('.note')).toHaveLength(3); submitForm(view.searchBox, 'new_test_query'); AjaxHelpers.respondWithJson(requests, { @@ -197,7 +201,7 @@ define([ rows: newNotes }); - expect(view.$('.edx-notes-page-item').length).toHaveLength(1); + 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 index 6e5e5ed38b09..32d131d82bcb 100644 --- a/lms/static/js/spec/edxnotes/views/tabs_list_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs_list_spec.js @@ -8,8 +8,8 @@ define([ customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); this.collection = new TabsCollection([ - {'class_name': 'first-item'}, - {'class_name': 'second-item'} + {identifier: 'first-item'}, + {identifier: 'second-item'} ]); this.tabsList = new TabsListView({ collection: this.collection @@ -17,8 +17,8 @@ define([ }); it('has correct order and class names', function () { - var firstItem = this.tabsList.$('.first-item'), - secondItem = this.tabsList.$('.second-item'); + var firstItem = this.tabsList.$('#first-item'), + secondItem = this.tabsList.$('#second-item'); expect(firstItem).toHaveIndex(0); expect(firstItem).toHaveClass('is-active'); @@ -26,25 +26,25 @@ define([ }); it('can add a new tab', function () { - var firstItem = this.tabsList.$('.first-item'), + var firstItem = this.tabsList.$('#first-item'), thirdItem; - this.collection.add({'class_name': 'third-item'}); - thirdItem = this.tabsList.$('.third-item'); + 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-item')).toHaveLength(3); + expect(this.tabsList.$('.tab')).toHaveLength(3); }); it('can remove tabs', function () { - var secondItem = this.tabsList.$('.second-item'); + var secondItem = this.tabsList.$('#second-item'); this.collection.at(0).destroy(); // remove first tab - expect(this.tabsList.$('.tab-item')).toHaveLength(1); + expect(this.tabsList.$('.tab')).toHaveLength(1); expect(secondItem).toHaveClass('is-active'); // second tab becomes active this.collection.at(0).destroy(); - expect(this.tabsList.$('.tab-item')).toHaveLength(0); + expect(this.tabsList.$('.tab')).toHaveLength(0); }); }); }); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 3a2d00262147..431d624f36c3 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -530,6 +530,7 @@ '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', @@ -539,7 +540,8 @@ 'lms/include/js/spec/edxnotes/views/tabs/recent_activity_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/tab_spec.js', + 'lms/include/js/spec/edxnotes/models/note_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index 150592c5cf94..abdcf2e10ce2 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -42,6 +42,7 @@ "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", "jquery.highlight": "js/vendor/jquery.highlight", // Files needed by OVA "annotator": "js/vendor/ova/annotator-full", diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 586a7fcd0b83..4ef7aa9e1ae1 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -117,3 +117,9 @@ padding-left: ($baseline/4); } } + +.edx-notes-visibility { + .error { + color: $red; + } +} diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 0cbca4715283..805713763c18 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -164,3 +164,20 @@ 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; +} diff --git a/lms/static/sass/course-rtl.scss.mako b/lms/static/sass/course-rtl.scss.mako index 762daf64cafc..1937a13eaeca 100644 --- a/lms/static/sass/course-rtl.scss.mako +++ b/lms/static/sass/course-rtl.scss.mako @@ -66,7 +66,7 @@ @import "course/staff_grading"; @import "course/rubric"; @import "course/open_ended_grading"; -@import "course/edxnotes"; +@import "course/student-notes"; // instructor @import "course/instructor/instructor"; diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 89415f4dadf6..d60c33c72e6b 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -66,7 +66,7 @@ @import "course/staff_grading"; @import "course/rubric"; @import "course/open_ended_grading"; -@import "course/edxnotes"; +@import "course/student-notes"; // instructor @import "course/instructor/instructor"; diff --git a/lms/static/sass/course/_edxnotes.scss b/lms/static/sass/course/_edxnotes.scss deleted file mode 100644 index ebbe8221f3eb..000000000000 --- a/lms/static/sass/course/_edxnotes.scss +++ /dev/null @@ -1,174 +0,0 @@ -.edx-notes-visibility { - .error { - color: $red; - } -} - -.edx-notes-page-wrapper { - header { - @include clearfix; - @include box-sizing(border-box); - margin: 0.5em auto 2em; - padding: 1em 2.5em; - max-width: grid-width(12); - min-width: 760px; - width: flex-grid(12); - border-width: 4px 0;; - border-color: #ccc; - border-style: solid; - - .page-title { - float: left; - max-width: 68%; - - h1 { - font-weight: 100; - margin: 0; - } - - small { - display: block; - } - } - - .search-box { - @include clearfix; - margin: 8px 0; - float: right; - text-align: right; - width: 40%; - - #search-field { - @include box-sizing(border-box); - width: 70%; - font-size: 1em; - padding: 0.9em 10px; - box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.6); - border: 1px solid #ccc; - } - - button { - @extend %btn-secondary-blue-outline; - margin: 2px 0 0 7px; - } - } - } - - .edx-notes-page-views { - @include clearfix; - @include box-sizing(border-box); - margin: 0 auto; - padding: 0px 2.5em; - max-width: grid-width(12); - min-width: 760px; - width: flex-grid(12); - - h4 { - display: inline-block; - color: $gray-l1; - font-weight: 600; - text-transform: uppercase; - font-size: 0.9em; - margin-right: 5em; - } - - .tabs { - display: inline-block; - list-style: none; - padding: 0; - margin: 0; - - .tab-item { - display: inline-block; - padding: 10px 20px; - margin: 0; - - &.is-active { - border-bottom: 3px solid #000; - } - } - } - } - - .container { - padding-top: 0; - } - - .course-info { - @extend .content; - display: block; - width: 100%; - font-size: 1em; - line-height: 1.6em; - - .inline-error { - color: $red; - border-bottom: 1px solid $red; - padding: 0 0 0.5em; - margin-bottom: 1em; - } - - .edx-notes-page-item { - @extend .clearfix; - padding-top: 2em; - margin-top: 2em; - border-top: 1px solid #ccc; - - &:first-child { - padding-top: 0; - margin-top: 0; - border-top: 0; - } - - .edx-notes-highlight { - background-color: #FFFF88; - } - } - - .col-left { - float: left; - width: 73%; - } - - .col-right { - float: right; - width: 25%; - - dl { - padding: 0; - margin: 0; - font-size: 0.9em; - - dt, dd { - padding: 0; - margin: 0; - } - dt { - color: $gray-l1; - font-weight: 600; - text-transform: uppercase; - } - - dd { - color: $gray-d4; - padding-bottom: 1em; - - a { - font-weight: 700; - } - } - } - } - - .edx-notes-item-quote { - background-color: #F5E4D1; - padding: 2em; - } - - .edx-notes-item-text { - background-color: #F6E0A0; - padding: 2em; - font-weight: 600; - } - } -} diff --git a/lms/static/sass/course/_student-notes.scss b/lms/static/sass/course/_student-notes.scss new file mode 100644 index 000000000000..4bc4163e2a09 --- /dev/null +++ b/lms/static/sass/course/_student-notes.scss @@ -0,0 +1,266 @@ +.wrapper-student-notes { + + .info-wrapper { + @include clearfix(); + padding-bottom: $baseline; + + .updates { + @include clearfix(); + width: 100%; + + .title-search-container { + border-bottom: 1px solid $gray-l4; + margin-bottom: $baseline; + + .wrapper-title { + display: inline-block; + width: flex-grid(7,12); + + .page-title { + font-weight: $font-light; + + .page-subtitle { + @include line-height(18); + @extend %t-title6; + display: block; + } + } + } + + .wrapper-notes-search { + display: inline-block; + width: flex-grid(5,12); + text-align: right; + + .search-notes-input { + width: 55%; + padding: 5px; + } + } + } + } + + .note { + @include clearfix(); + margin: 0; + padding: ($baseline*1.5) 0; + border-top: 1px solid $gray-l4; + border-bottom: none; + + .wrapper-note-excerpts { + display: inline-block; + width: flex-grid(9, 12); + + .note-excerpt { + display: inline-block; + vertical-align: top; + background: $m-blue-l4; + + .note-excerpt-p, + .note-excerpt-ul, + .note-excerpt-ol { + @extend %t-copy-base; + position: relative; + padding: $baseline ($baseline*4) $baseline ($baseline*4); + } + } + + .note-excerpt > .note-excerpt-p:before, + .note-excerpt > .note-excerpt-ul:before, + .note-excerpt > .note-excerpt-ol:before, + .note-excerpt > .note-excerpt-p:after, + .note-excerpt > .note-excerpt-ul:after, + .note-excerpt > .note-excerpt-ol:after { + @extend %t-title3; + display: block; + position: absolute; + height: 40px; + width: 40px; + font-family: "FontAwesome"; + // color: $gray-l4; + color: $white; + } + + .note-excerpt > .note-excerpt-p:before, + .note-excerpt > .note-excerpt-ul:before, + .note-excerpt > .note-excerpt-ol:before { + content: "\f10d"; + top: 0; + left: $baseline; + } + + .note-excerpt > .note-excerpt-p:after, + .note-excerpt > .note-excerpt-ul:after, + .note-excerpt > .note-excerpt-ol:after { + content: "\f10e"; + bottom: ($baseline/2); + right: $baseline; + } + + .note-comments { + position: relative; + margin: 0; + padding: 0; + list-style: none; + // background: $m-blue-l4; + + .note-comment { + padding: ($baseline/2) $baseline; + border-bottom: 2px solid $white; + + .note-comment-p, + .note-comment-ul, + .note-comment-ol { + @extend %t-weight4; + padding: 0; + margin: 0; + background: transparent; + } + + .note-comment-p { + overflow: hidden; + text-overflow: ellipsis; + } + + .note-comment-ul, + .note-comment-ol { + padding: auto; + margin: auto; + } + + .note-highlight { + background-color: #FFFF88; + } + } + } + + .note-comments:before { + @extend %t-title4; + display: block; + position: absolute; + top: -14px; + left: 20px; + height: 24px; + width: 24px; + font-family: "FontAwesome"; + line-height: 20px; + content: "\f0d8"; + // color: $m-blue-l4; + color: $white; + } + } + + .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-copy-sub1; + margin-top: $baseline; + text-transform: uppercase; + font-weight: $font-regular; + letter-spacing: 1px; + color: $gray-l2; + } + + .reference-title:first-child { + margin-top: 0; + } + + .reference-meta { + font-weight: $font-regular; + color: $m-gray-d2; + } + + a.reference-meta { + color: $link-color; + + &:hover, + &:focus { + color: $link-hover; + } + } + } + } + } + + .note-group { + padding-top: ($baseline*1.5); + border-top: 1px solid $gray-l4; + + .group-lecture { + border-bottom: 2px solid $gray-l4; + padding-bottom: $baseline; + text-transform: uppercase; + letter-spacing: 1px; + + .course-subtitle { + @extend %t-copy-sub1; + display: block; + font-weight: $font-light; + color: inherit; + } + } + } + } +} + +.wrapper-tabs { + + .tab-panel, .inline-error, .ui-loading { + outline: none; + } + + .inline-error { + color: $red; + border-bottom: 1px solid $red; + padding: 0 0 0.5em; + margin-bottom: 1em; + } + + .tab-list { + @include clearfix(); + + .tabs-label { + @extend %t-copy-base; + @include line-height(14); + display: inline; + margin-bottom: 0; + padding: ($baseline/2) $baseline; + padding-left: 0; + font-weight: $font-bold; + } + + .tabs { + @include clearfix(); + display: inline; + margin: 0; + padding: 0; + list-style: none; + + .tab { + display: inline; + + .tab-label { + @include transition(none); + display: inline-block; + padding: ($baseline/2) $baseline; + } + + &.is-active { + + .tab-label { + color: $gray-d3; + border-bottom: ($baseline/5) solid $gray-d3; + } + } + } + } + } +} diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 07ab12dfe5e0..8ef87b29138c 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -4,45 +4,75 @@ <%namespace name='static' file='/static_content.html'/> <%inherit file="/main.html" /> -<%block name="pagetitle">${_("Notes")} +<%block name="pagetitle">${_("Student Notes")} <%block name="headextra"> <%static:css group='style-course'/> <%include file="/courseware/course_navigation.html" args="active_page='edxnotes'" /> -
    -
    -
    -

    ${_('Notes')}

    - ${_("Highlights and personal notes you've made within the course")} -
    - % if notes: - - % endif -
    - -
    -
    - - % if notes: -
    -

    ${_("Loading...")}

    +
    +
    +
    +
    +
    +

    ${_('Notes')} ${_("Highlights and personal notes you've made within the course")}

    +
    + % if notes: + + % endif
    - % else: -
    ${_('You do not have any notes within the course.')}
    - % endif -
    + +
    + + + % if notes: +
    +

    ${_("Loading...")}

    +
    + % else: +
    +
    +

    ${_('You have not made any notes in this course yet.')}

    +

    ${_('Lorem ipsum dolor sit amet, consectetur 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.')}

    + +

    ${_('Other Students In This Course Are Using Notes To:')}

    +
      +
    • ${_('Add study notes to course content for exams and homework.')}
    • +
    • ${_('Highlight important concepts for later coursework or future courses')}
    • +
    • ${_('Donec id elit non mi porta gravida et eget metus.')}
    • +
    • ${_('Praesent commodo cursus magna, vel scelerisque nisl consectetur et.')}
    • +
    + + % if position is not None: +
    +

    ${_('Start creating notes')}

    +

    ${_('Get started by making a note in something you just read, like {section_link}.').format( + section_link='{section_name}'.format( + url=position['url'], + section_name=position['display_name'], + ) + )}

    +
    + % endif +
    +
    + % endif +
    +
    -
    + ## Include Underscore templates <%block name="header_extras"> -% for template_name in ["recent-activity-item", "tab-item"]: +% for template_name in ["note-item", "tab-item"]: diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore new file mode 100644 index 000000000000..80414c0fd359 --- /dev/null +++ b/lms/templates/edxnotes/note-item.underscore @@ -0,0 +1,36 @@ +
    + <% if (message) { %> +
    +

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

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

      <%= text %>

      +
    2. +
    + <% } %> +
    +
    +
    + <% if (text && quote) { %> +

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

    + <% } else if (text) { %> +

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

    + <% } else if (quote) { %> +

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

    + <% } %> + <%- unit.display_name %> +

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

    + <%- updated %> +
    +
    diff --git a/lms/templates/edxnotes/recent-activity-item.underscore b/lms/templates/edxnotes/recent-activity-item.underscore deleted file mode 100644 index 3d997880ed9a..000000000000 --- a/lms/templates/edxnotes/recent-activity-item.underscore +++ /dev/null @@ -1,28 +0,0 @@ -<% collection.each(function (model) { %> -
    -
    - <% if (model.get('quote')) { %> -
    <%= model.escape('quote') %>
    - <% } %> - <% if (model.get('text')) { %> -
    <%= model.escape('text') %>
    - <% } %> -
    -
    -
    - <% if (model.get('text') && model.get('quote')) { %> -
    <%- gettext("Highlighted & Noted in:") %>
    - <% } else if (model.get('text')) { %> -
    <%- gettext("Highlighted in:") %>
    - <% } else if (model.get('quote')) { %> -
    <%- gettext("Noted in:") %>
    - <% } %> -
    <%- model.get('unit').display_name %>
    - <% if (model.get('updated')) { %> -
    <%- gettext("Last Edited:") %>
    -
    <%- model.get('updated') %>
    - <% } %> -
    -
    -
    -<% }) %> diff --git a/lms/templates/edxnotes/tab-item.underscore b/lms/templates/edxnotes/tab-item.underscore index a5eff00dc619..0f1e6eaa230c 100644 --- a/lms/templates/edxnotes/tab-item.underscore +++ b/lms/templates/edxnotes/tab-item.underscore @@ -1,4 +1,5 @@ -<%- gettext(name) %> -<% if (is_closable) { %> - x +<% var hasIcon = icon ? 1 : 0; %> +<% if (hasIcon) { %> <% } %><%- gettext(name) %><% if (is_closable) { %> + x <% } %> + From 2824fb3021b9e750b346680bdb2a7f4f6d4c1de2 Mon Sep 17 00:00:00 2001 From: Jean-Michel Claus Date: Wed, 10 Dec 2014 16:19:19 -0500 Subject: [PATCH 09/47] TNL-784: Scroll and opening of notes. --- .../acceptance/tests/lms/test_lms_edxnotes.py | 157 ++++++++++-------- lms/static/js/edxnotes/plugins/scroller.js | 65 ++++++++ lms/static/js/edxnotes/views/notes_factory.js | 5 +- lms/static/js/edxnotes/views/shim.js | 34 +++- .../js/spec/edxnotes/custom_matchers.js | 2 +- .../js/spec/edxnotes/plugins/scroller_spec.js | 94 +++++++++++ .../js/spec/edxnotes/views/shim_spec.js | 9 + lms/static/js/spec/main.js | 4 +- lms/static/require-config-lms.js | 5 +- lms/templates/edxnotes/note-item.underscore | 2 +- 10 files changed, 291 insertions(+), 86 deletions(-) create mode 100644 lms/static/js/edxnotes/plugins/scroller.js create mode 100644 lms/static/js/spec/edxnotes/plugins/scroller_spec.js diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index c6cfe39c477a..3436e49a6c11 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -40,34 +40,36 @@ def setUp(self): self.course_fixture.add_children( XBlockFixtureDesc("chapter", "Test Section").add_children( XBlockFixtureDesc("sequential", "Test Subsection 1").add_children( - XBlockFixtureDesc("vertical", "Test Vertical").add_children( + XBlockFixtureDesc("vertical", "Test Unit 1").add_children( XBlockFixtureDesc( "html", "Test HTML 1", data=""" -

    Annotate this text!

    +

    Annotate this text!

    Annotate this text

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

    Annotate this text!

    """.format(self.selector) + data="""

    Annotate this text!

    """.format(self.selector) ), ), - XBlockFixtureDesc( - "html", - "Test HTML 3", - 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 Vertical").add_children( + XBlockFixtureDesc("vertical", "Test Unit 3").add_children( XBlockFixtureDesc( "html", "Test HTML 4", data=""" -

    Annotate this text!

    +

    Annotate this text!

    Annotate this text

    """.format(self.selector) ), @@ -234,58 +236,10 @@ def test_can_delete_notes(self): self.assert_notes_are_removed(components) -class EdxNotesPageTest(UniqueCourseTest): +class EdxNotesPageTest(EdxNotesTestMixin): """ Tests for Notes page. """ - def setUp(self): - """ - Initialize pages and install a course fixture. - """ - super(EdxNotesPageTest, self).setUp() - self.courseware_page = CoursewarePage(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.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").add_children( - XBlockFixtureDesc("sequential", "Test Subsection").add_children( - XBlockFixtureDesc("vertical", "Test Unit 1").add_children( - XBlockFixtureDesc( - "html", - "Test HTML 1", - data="""

    Annotate this text!

    """ - ), - ), - XBlockFixtureDesc("vertical", "Test Unit 2").add_children( - XBlockFixtureDesc("vertical", "Test Unit 2").add_children( - XBlockFixtureDesc( - "html", - "Test HTML 2", - data="""

    Third text!

    """ - ), - ), - ), - ), - )).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, notes_list): self.edxnotes_fixture.create_notes(notes_list) self.edxnotes_fixture.install() @@ -294,10 +248,10 @@ def _add_default_notes(self): xblocks = self.course_fixture.get_nested_xblocks(category="html") self._add_notes([ Note( - usage_id=xblocks[1].locator, + usage_id=xblocks[2].locator, user=self.username, course_id=self.course_fixture._course_key, - text="Third text", + text="Third note", quote="", updated=datetime(2012, 1, 1, 1, 1, 1, 1).isoformat(), ), @@ -305,17 +259,17 @@ def _add_default_notes(self): usage_id=xblocks[0].locator, user=self.username, course_id=self.course_fixture._course_key, - text="Annotate this text", - quote="Second note", + text="Second 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[1].locator, + usage_id=xblocks[2].locator, user=self.username, course_id=self.course_fixture._course_key, text="", - quote="First note", + quote="Annotate this text", updated=datetime(2014, 1, 1, 1, 1, 1, 1).isoformat(), ), ]) @@ -363,22 +317,22 @@ def assertContent(item, text=None, quote=None, unit_name=None, time_updated=None self.assertEqual(len(items), 3) assertContent( items[0], - quote=u"First note", + quote=u"Annotate this text", unit_name="Test Unit 2", time_updated="Jan 01, 2014 at 01:01 UTC" ) assertContent( items[1], - text="Annotate this text", - quote=u"Second note", + text=u"Second note", + quote="Annotate this text", unit_name="Test Unit 1", time_updated="Jan 01, 2013 at 01:01 UTC" ) assertContent( items[2], - text=u"Third text", + text=u"Third note", unit_name="Test Unit 2", time_updated="Jan 01, 2012 at 01:01 UTC" ) @@ -394,7 +348,7 @@ def test_easy_access_from_notes_page(self): self._add_default_notes() self.notes_page.visit() item = self.notes_page.children[1] - text = item.text + text = item.quote item.go_to_unit() self.courseware_page.wait_for_page() self.assertIn(text, self.courseware_page.xblock_component_html_content()) @@ -407,7 +361,7 @@ def test_search_behaves_correctly(self): When I run the search with " " query Then I see the following error message "Search field cannot be blank." And I still can see only "Recent Activity" tab - When I run the search with "text" query + When I run the search with "note" query Then I see that error message disappears And I see that "Search Results" tab appears with 2 notes found """ @@ -421,7 +375,7 @@ def test_search_behaves_correctly(self): # Search results tab does not appear self.assertEqual(len(self.notes_page.tabs), 1) # Run the search with correct query - self.notes_page.search("text") + 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) @@ -433,7 +387,7 @@ def test_tabs_behaves_correctly(self): Given I have a course with 3 notes When I open Notes page Then I see only "Recent Activity" tab with 3 notes - When I run the search with "text" query + When I run the search with "note" query And I see that "Search Results" tab appears with 2 notes found Then I switch to "Recent Activity" tab And I see all 3 notes @@ -451,7 +405,7 @@ def test_tabs_behaves_correctly(self): self.assertEqual(len(self.notes_page.tabs), 1) self.assertIn(u"Recent Activity", self.notes_page.tabs) self.assertEqual(len(self.notes_page.children), 3) - self.notes_page.search("text") + self.notes_page.search("note") # We're on Search Results tab self.assertEqual(len(self.notes_page.tabs), 2) self.assertIn(u"Search Results", self.notes_page.tabs) @@ -467,6 +421,63 @@ def test_tabs_behaves_correctly(self): self.assertIn(u"Recent Activity", self.notes_page.tabs) self.assertEqual(len(self.notes_page.children), 3) + 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.children[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): """ diff --git a/lms/static/js/edxnotes/plugins/scroller.js b/lms/static/js/edxnotes/plugins/scroller.js new file mode 100644 index 000000000000..0c717aff45fd --- /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/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js index b52d35c66b51..f1030cc5cb51 100644 --- a/lms/static/js/edxnotes/views/notes_factory.js +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -1,9 +1,10 @@ ;(function (define, undefined) { 'use strict'; define([ - 'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger', 'js/edxnotes/views/shim' + 'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger', + 'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller' ], function ($, _, Annotator, Logger) { - var plugins = ['Auth', 'Store'], + var plugins = ['Auth', 'Store', 'Scroller'], getOptions, setupPlugins, updateHeaders, getAnnotator; /** diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index 6900892f8937..3f7e3f861f1b 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -79,7 +79,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { // 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) { - _.invoke(Annotator._instances, 'unfreeze'); + this.unfreezeAll(); } else { // Unfreeze only this instance and unbound associated 'click.edxnotes:freeze' handler $(document).off('click.edxnotes:freeze' + this.uid); @@ -89,6 +89,8 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { if (this.logger && this.logger.destroy) { this.logger.destroy(); } + // Unbind onNoteClick from click + this.viewer.element.off('click', this.onNoteClick); } ); @@ -112,6 +114,17 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { '' ].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', @@ -129,7 +142,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { this.onHighlightMouseover.call(this, event); } Annotator.frozenSrc = this; - _.invoke(Annotator._instances, 'freeze'); + this.freezeAll(); }, onNoteClick: function (event) { @@ -137,7 +150,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { Annotator.Util.preventEventDefault(event); if (!$(event.target).is('.annotator-delete')) { Annotator.frozenSrc = this; - _.invoke(Annotator._instances, 'freeze'); + this.freezeAll(); } }, @@ -147,7 +160,7 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { this.removeEvents(); this.viewer.element.unbind('mouseover mouseout'); this.uid = _.uniqueId(); - $(document).on('click.edxnotes:freeze'+this.uid, this.unfreeze.bind(this)); + $(document).on('click.edxnotes:freeze' + this.uid, _.bind(this.unfreeze, this)); this.isFrozen = true; } }, @@ -165,6 +178,19 @@ define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) { 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(); } }); }); diff --git a/lms/static/js/spec/edxnotes/custom_matchers.js b/lms/static/js/spec/edxnotes/custom_matchers.js index 6521198768d3..c5309bd6c979 100644 --- a/lms/static/js/spec/edxnotes/custom_matchers.js +++ b/lms/static/js/spec/edxnotes/custom_matchers.js @@ -26,7 +26,7 @@ define(['jquery'], function($) { toBeFocused: function () { return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement; - }, + } }); }; }); 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 000000000000..b60eb86cb31f --- /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/views/shim_spec.js b/lms/static/js/spec/edxnotes/views/shim_spec.js index 0f82f4ed36e3..3e6208b2e858 100644 --- a/lms/static/js/spec/edxnotes/views/shim_spec.js +++ b/lms/static/js/spec/edxnotes/views/shim_spec.js @@ -42,6 +42,7 @@ define([ spyOn(annotator, 'onHighlightMouseover').andCallThrough(); spyOn(annotator, 'startViewerHideTimer').andCallThrough(); }); + spyOn($.fn, 'off').andCallThrough(); }); afterEach(function () { @@ -112,5 +113,13 @@ define([ // 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/main.js b/lms/static/js/spec/main.js index 431d624f36c3..a09dce3cafbb 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -541,7 +541,9 @@ '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/models/note_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/require-config-lms.js b/lms/static/require-config-lms.js index abdcf2e10ce2..931f24607bda 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -143,10 +143,7 @@ // End of needed by OVA }, map: { - "js/edxnotes/views/notes_factory": { - "annotator": "annotator_1.2.9" - }, - "js/edxnotes/views/shim": { + "js/edxnotes/*": { "annotator": "annotator_1.2.9" } } diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore index 80414c0fd359..f6bc8d3d2183 100644 --- a/lms/templates/edxnotes/note-item.underscore +++ b/lms/templates/edxnotes/note-item.underscore @@ -29,7 +29,7 @@ <% } else if (quote) { %>

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

    <% } %> - <%- unit.display_name %> + <%- unit.display_name %>

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

    <%- updated %>
    From de634419f8685f3348663424e9f5d007a2901ee8 Mon Sep 17 00:00:00 2001 From: polesye Date: Fri, 5 Dec 2014 14:17:43 +0200 Subject: [PATCH 10/47] TNL-762: Add course structure view. Co-Authored-By: Tim Babych --- common/test/acceptance/pages/lms/edxnotes.py | 157 ++++++--- .../acceptance/tests/lms/test_lms_edxnotes.py | 309 ++++++++++++++---- lms/djangoapps/edxnotes/helpers.py | 143 +++++--- lms/djangoapps/edxnotes/tests.py | 225 ++++++++++--- lms/djangoapps/edxnotes/views.py | 7 +- lms/envs/common.py | 3 + lms/static/js/edxnotes/collections/notes.js | 36 +- lms/static/js/edxnotes/models/note.js | 30 +- lms/static/js/edxnotes/utils/template.js | 22 ++ lms/static/js/edxnotes/views/note_group.js | 70 ++++ lms/static/js/edxnotes/views/note_item.js | 19 +- lms/static/js/edxnotes/views/notes_page.js | 13 +- lms/static/js/edxnotes/views/tab_item.js | 13 +- lms/static/js/edxnotes/views/tab_panel.js | 54 +++ lms/static/js/edxnotes/views/tab_view.js | 5 +- .../edxnotes/views/tabs/course_structure.js | 54 +++ .../js/edxnotes/views/tabs/recent_activity.js | 33 +- .../js/edxnotes/views/tabs/search_results.js | 62 ++-- lms/static/js/spec/edxnotes/base64.js | 54 --- .../spec/edxnotes/collections/notes_spec.js | 34 ++ lms/static/js/spec/edxnotes/helpers.js | 161 +++++++++ .../js/spec/edxnotes/models/note_spec.js | 30 +- .../js/spec/edxnotes/views/note_item_spec.js | 39 +-- .../spec/edxnotes/views/notes_factory_spec.js | 8 +- .../js/spec/edxnotes/views/notes_page_spec.js | 43 +-- .../js/spec/edxnotes/views/tab_view_spec.js | 2 +- .../views/tabs/course_structure_spec.js | 67 ++++ .../views/tabs/recent_activity_spec.js | 2 +- .../views/tabs/search_results_spec.js | 2 +- .../views/toggle_notes_factory_spec.js | 9 +- .../views/visibility_decorator_spec.js | 6 +- lms/static/js/spec/main.js | 1 + lms/static/sass/course/_student-notes.scss | 299 +++++++++-------- 33 files changed, 1431 insertions(+), 581 deletions(-) create mode 100644 lms/static/js/edxnotes/utils/template.js create mode 100644 lms/static/js/edxnotes/views/note_group.js create mode 100644 lms/static/js/edxnotes/views/tab_panel.js create mode 100644 lms/static/js/edxnotes/views/tabs/course_structure.js delete mode 100644 lms/static/js/spec/edxnotes/base64.js create mode 100644 lms/static/js/spec/edxnotes/collections/notes_spec.js create mode 100644 lms/static/js/spec/edxnotes/helpers.js create mode 100644 lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index 5275e3d06d70..52ea477cf9ca 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -26,6 +26,86 @@ def _bounded_selector(self, selector): 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-comments") + + @property + def quote(self): + return self._get_element_text(".note-excerpt") + + @property + def time_updated(self): + return self._get_element_text(".reference-updated-date") + + @property + def title_highlighted(self): + return self._get_element_text(".reference-title") + class EdxNotesPageView(PageObject): """ @@ -35,6 +115,7 @@ class EdxNotesPageView(PageObject): BODY_SELECTOR = ".tab-panel" TAB_SELECTOR = ".tab" CHILD_SELECTOR = ".note" + CHILD_CLASS = EdxNotesPageItem @unguarded def visit(self): @@ -79,7 +160,7 @@ def children(self): Returns all notes on the page. """ children = self.q(css=self.CHILD_SELECTOR) - return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children] + return [self.CHILD_CLASS(self.browser, child.get_attribute("id")) for child in children] class RecentActivityView(EdxNotesPageView): @@ -90,6 +171,16 @@ class RecentActivityView(EdxNotesPageView): TAB_SELECTOR = ".tab#view-recent-activity" +class CourseStructureView(EdxNotesPageView): + """ + Helper class for Course Structure 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. @@ -105,6 +196,7 @@ class EdxNotesPage(CoursePage): url_path = "edxnotes" MAPPING = { "recent": RecentActivityView, + "structure": CourseStructureView, "search": SearchResultsView, } @@ -138,6 +230,8 @@ def search(self, text): # 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): @@ -169,11 +263,28 @@ def error_text(self): return None @property - def children(self): + def notes(self): """ Returns all notes on the page. """ - return self.current_view.children + 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): @@ -187,46 +298,6 @@ def no_content_text(self): return None -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 _get_element_text(self, selector): - element = self.q(css=self._bounded_selector(selector)).first - if element: - return element.text[0] - else: - return None - - 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-comments") - - @property - def quote(self): - return self._get_element_text(".note-excerpt") - - @property - def time_updated(self): - return self._get_element_text(".reference-updated-date") - - @property - def title_highlighted(self): - return self._get_element_text(".reference-title") - - class EdxNotesUnitPage(CoursePage): """ Page for the Unit with EdxNotes. diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 3436e49a6c11..6e1318db8d86 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -38,14 +38,14 @@ def setUp(self): }) self.course_fixture.add_children( - XBlockFixtureDesc("chapter", "Test Section").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!

    Annotate this text

    """.format(self.selector) ), @@ -69,10 +69,27 @@ def setUp(self): "html", "Test HTML 4", data=""" -

    Annotate this text!

    -

    Annotate this text

    +

    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() @@ -247,33 +264,75 @@ def _add_notes(self, notes_list): 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="Third note", - quote="", + 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="Second note", + 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[2].locator, + usage_id=xblocks[3].locator, user=self.username, course_id=self.course_fixture._course_key, - text="", - quote="Annotate this text", + 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) + if text is not None and quote is not None: + self.assertEqual(item.title_highlighted, "HIGHLIGHTED & NOTED IN:") + elif text is not None: + self.assertEqual(item.title_highlighted, "HIGHLIGHTED IN:") + elif quote is not None: + self.assertEqual(item.title_highlighted, "NOTED IN:") + + 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. @@ -287,83 +346,187 @@ def test_no_content(self): def test_recent_activity_view(self): """ Scenario: User can view all notes by recent activity. - Given I have a course with 3 notes + Given I have a course with 5 notes When I open Notes page - Then I see 3 notes sorted by the day + Then I see 5 notes sorted by the updated date And I see correct content in the notes """ self._add_default_notes() - - def assertContent(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) - if text is not None and quote is not None: - self.assertEqual(item.title_highlighted, "HIGHLIGHTED & NOTED IN:") - elif text is not None: - self.assertEqual(item.title_highlighted, "HIGHLIGHTED IN:") - elif quote is not None: - self.assertEqual(item.title_highlighted, "NOTED IN:") - self.notes_page.visit() - items = self.notes_page.children - self.assertEqual(len(items), 3) - assertContent( - items[0], + notes = self.notes_page.notes + self.assertEqual(len(notes), 5) + + self.assertNoteContent( + notes[0], quote=u"Annotate this text", - unit_name="Test Unit 2", + 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" ) - assertContent( - items[1], - text=u"Second note", + 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" ) - assertContent( - items[2], + 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 course structure. + Given I have a course with 5 notes + When I open Notes page + And I switch to "Course Structure" 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 3 notes + 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 "Course Structure" 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() - item = self.notes_page.children[1] - text = item.quote - item.go_to_unit() - self.courseware_page.wait_for_page() - self.assertIn(text, self.courseware_page.xblock_component_html_content()) + 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 3 notes + 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 "Search field cannot be blank." - And I still can see only "Recent Activity" tab + 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 2 notes found + And I see that "Search Results" tab appears with 4 notes found """ self._add_default_notes() self.notes_page.visit() @@ -373,53 +536,57 @@ def test_search_behaves_correctly(self): self.assertTrue(self.notes_page.is_error_visible) self.assertEqual(self.notes_page.error_text, u"Search field cannot be blank.") # Search results tab does not appear - self.assertEqual(len(self.notes_page.tabs), 1) + 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.children), 2) + self.assertEqual(len(self.notes_page.notes), 4) def test_tabs_behaves_correctly(self): """ Scenario: Tabs behaves correctly. - Given I have a course with 3 notes + Given I have a course with 5 notes When I open Notes page - Then I see only "Recent Activity" tab with 3 notes + Then I see only "Recent Activity" and "Course Structure" tabs When I run the search with "note" query - And I see that "Search Results" tab appears with 2 notes found + And I see that "Search Results" tab appears with 4 notes found Then I switch to "Recent Activity" tab - And I see all 3 notes + And I see all 5 notes + Then I switch to "Course Structure" tab + And I see all 2 groups and 5 notes When I switch back to "Search Results" tab - Then I can still see 2 notes found + 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 3 notes + 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), 1) - self.assertIn(u"Recent Activity", self.notes_page.tabs) - self.assertEqual(len(self.notes_page.children), 3) + self.assertEqual(len(self.notes_page.tabs), 2) + self.assertEqual([u"Recent Activity", u"Course Structure"], self.notes_page.tabs) self.notes_page.search("note") # We're on Search Results tab - self.assertEqual(len(self.notes_page.tabs), 2) + self.assertEqual(len(self.notes_page.tabs), 3) self.assertIn(u"Search Results", self.notes_page.tabs) - self.assertEqual(len(self.notes_page.children), 2) + 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.children), 3) + 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.children), 2) + 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), 1) - self.assertIn(u"Recent Activity", self.notes_page.tabs) - self.assertEqual(len(self.notes_page.children), 3) + 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): """ @@ -464,7 +631,7 @@ def test_open_note_when_accessed_from_notes_page(self): ), ]) self.notes_page.visit() - item = self.notes_page.children[0] + item = self.notes_page.notes[0] item.go_to_unit() self.courseware_page.wait_for_page() note = self.note_unit_page.notes[0] @@ -579,7 +746,7 @@ def test_can_disable_all_notes(self): 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", u"Test Subsection 2") + 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): @@ -605,5 +772,5 @@ def test_can_reenable_all_notes(self): 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", u"Test Subsection 2") + 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/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 834a4d83090f..21015249800f 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -101,6 +101,21 @@ def send_request(user, course_id, path="", query_string=""): return response +def get_parent_unit(xblock): + """ + Find vertical that is a unit, not just some container. + """ + while xblock: + xblock = xblock.get_parent() + if xblock is None: + return None + parent = xblock.get_parent() + if parent is None: + return None + if parent.category == 'sequential': + return xblock + + def preprocess_collection(user, course, collection): """ Reprocess provided `collection(list)`: adds information about ancestor, @@ -109,31 +124,105 @@ def preprocess_collection(user, course, collection): Raises: ItemNotFoundError - when appropriate module is not found. """ + store = modulestore() filtered_collection = list() + cache = {} with store.bulk_operations(course.id): for model in collection: - usage_key = course.id.make_usage_key_from_deprecated_string(model["usage_id"]) + model.update({ + u"text": markupsafe.escape(model["text"]), + u"quote": markupsafe.escape(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 = course.id.make_usage_key_from_deprecated_string(usage_id) try: item = store.get_item(usage_key) except ItemNotFoundError: - log.warning("Module not found: %s", usage_key) + 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 - model.update({ - u"text": markupsafe.escape(model["text"]), - u"quote": markupsafe.escape(model["quote"]), - u"unit": get_ancestor_context(course, store, usage_key), - u"updated": dateutil_parse(model["updated"]), - }) + unit = get_parent_unit(item) + if unit is None: + log.debug("Unit not found: %s", usage_key) + continue + + section = unit.get_parent() + 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 = section.get_parent() + 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': item.location.to_deprecated_string(), + 'display_name': item.display_name_with_default, + } + + if item.category == 'chapter' and item.get_parent(): + course = item.get_parent() + ancestor_children = [child.to_deprecated_string() for child in course.children] + item_dict['index'] = ancestor_children.index(item_dict['location']) + elif item.category == 'vertical': + item_dict['url'] = reverse("jump_to_id", kwargs={ + "course_id": course.id.to_deprecated_string(), + "module_id": item.url_name, + }) + + if item.category in ('chapter', 'sequential'): + item_dict['children'] = [child.to_deprecated_string() for child in item.children] + + return item_dict + + def search(user, course, query_string): """ Returns search results for the `query_string(str)`. @@ -169,44 +258,6 @@ def get_notes(user, course): return json.dumps(preprocess_collection(user, course, collection), cls=NoteJSONEncoder) -def get_ancestor(store, usage_key): - """ - Returns ancestor module for the passed `usage_key`. - """ - location = store.get_parent_location(usage_key) - if not location: - log.warning("Parent location for the module not found: %s", usage_key) - return - try: - return store.get_item(location) - except ItemNotFoundError: - log.warning("Parent module not found: %s", location) - return - - -def get_ancestor_context(course, store, usage_key): - """ - Returns dispay_name and url for the parent module. - """ - parent = get_ancestor(store, usage_key) - - if not parent: - return { - u"display_name": None, - u"url": None, - } - - url = reverse("jump_to", kwargs={ - "course_id": course.id.to_deprecated_string(), - "location": parent.location.to_deprecated_string(), - }) - - return { - u"display_name": parent.display_name_with_default, - u"url": url, - } - - def get_endpoint(path=""): """ Returns endpoint. diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 8d398505a67b..5f9918152bbd 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -17,9 +17,9 @@ from oauth2_provider.tests.factories import ClientFactory from provider.oauth2.models import Client from xmodule.tabs import EdxNotesTab -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore.exceptions import ItemNotFoundError +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 @@ -125,7 +125,7 @@ def test_edxnotes_studio(self): @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") -class EdxNotesHelpersTest(TestCase): +class EdxNotesHelpersTest(ModuleStoreTestCase): """ Tests for EdxNotes helpers. """ @@ -133,23 +133,42 @@ def setUp(self): """ Setup a dummy course content. """ + super(EdxNotesHelpersTest, self).setUp() 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.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + 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_jump_to_url(self, vertical): """ - Returns `jump_to` url for the `vertical`. + Returns `jump_to_id` url for the `vertical`. """ - return reverse("jump_to", kwargs={ + return reverse("jump_to_id", kwargs={ "course_id": self.course.id.to_deprecated_string(), - "location": vertical.location.to_deprecated_string(), + "module_id": vertical.url_name, }) def test_edxnotes_not_enabled(self): @@ -219,9 +238,21 @@ def test_get_notes_correct_data(self, mock_get): { 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_jump_to_url(self.vertical), 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", @@ -229,9 +260,21 @@ def test_get_notes_correct_data(self, mock_get): { 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_jump_to_url(self.vertical), 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", @@ -286,9 +329,21 @@ def test_search_correct_data(self, mock_get): { 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_jump_to_url(self.vertical), 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", @@ -296,9 +351,21 @@ def test_search_correct_data(self, mock_get): { 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_jump_to_url(self.vertical), 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", @@ -356,9 +423,21 @@ def test_preprocess_collection_escaping(self): [{ 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_jump_to_url(self.vertical), 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), @@ -389,9 +468,21 @@ def test_preprocess_collection_no_item(self): [{ 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_jump_to_url(self.vertical), 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), @@ -401,7 +492,7 @@ def test_preprocess_collection_no_item(self): def test_preprocess_collection_has_access(self): """ - Tests the result if the user do not has access to some modules. + Tests the result if the user does not have access to some of the modules. """ initial_collection = [ { @@ -418,14 +509,26 @@ def test_preprocess_collection_has_access(self): }, ] self.html_module_2.visible_to_staff_only = True - modulestore().update_item(self.html_module_2, self.user.id) + 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_jump_to_url(self.vertical), 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), @@ -433,53 +536,101 @@ def test_preprocess_collection_has_access(self): helpers.preprocess_collection(self.user, self.course, initial_collection) ) - def test_get_ancestor(self): + @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_item().get_parent.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_unit(self): """ - Tests `test_get_ancestor` method for the successful result. + Tests `test_get_parent_unit` method for the successful result. """ - parent = helpers.get_ancestor(modulestore(), self.html_module_1.location) + parent = helpers.get_parent_unit(self.html_module_1) self.assertEqual(parent.location, self.vertical.location) - def test_get_ancestor_no_location(self): + 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_vertical(self): """ - Tests the result if parent location is not found. + Tests `test_get_module_context` method for the vertical. """ - store = MagicMock() - store.get_parent_location.return_value = None - self.assertEqual(helpers.get_ancestor(store, self.html_module_1.location), None) + self.assertDictEqual( + { + u"url": self._get_jump_to_url(self.vertical), + u"display_name": self.vertical.display_name_with_default, + u"location": unicode(self.vertical.location), + }, + helpers.get_module_context(self.course, self.vertical) + ) - def test_get_ancestor_no_parent(self): + def test_get_module_context_sequential(self): """ - Tests the result if ancestor module is not found. + Tests `test_get_module_context` method for the sequential. """ - store = MagicMock() - store.get_item.side_effect = ItemNotFoundError - self.assertEqual(helpers.get_ancestor(store, self.html_module_1.location), None) + 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_ancestor_context(self): + def test_get_module_context_html_component(self): """ - Tests `test_get_ancestor_context` method for the successful result. + Tests `test_get_module_context` method for the sequential. """ self.assertDictEqual( { - u"url": self._get_jump_to_url(self.vertical), - u"display_name": self.vertical.display_name_with_default, + u"display_name": self.html_module_1.display_name_with_default, + u"location": unicode(self.html_module_1.location), }, - helpers.get_ancestor_context(self.course, modulestore(), self.html_module_1.location) + helpers.get_module_context(self.course, self.html_module_1) ) - # pylint: disable=unused-argument - @patch("edxnotes.helpers.get_ancestor", return_value=None) - def test_get_ancestor_context_no_parent(self, mock_get_ancestor): + def test_get_module_context_chapter(self): """ - Tests the result if parent module is not found. + Tests `test_get_module_context` method for the chapters. """ - self.assertEqual( + 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"url": None, - u"display_name": None, + u"display_name": self.chapter_2.display_name_with_default, + u"index": 1, + u"location": unicode(self.chapter_2.location), + u"children": [], }, - helpers.get_ancestor_context(self.course, modulestore(), self.html_module_1.location) + helpers.get_module_context(self.course, self.chapter_2) ) @patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}) @@ -722,7 +873,7 @@ def test_search_notes_exception(self, mock_search): @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_get_id_token(self): """ - Test generation of ID Token + Test generation of ID Token. """ response = self.client.get(self.get_token_url) self.assertEqual(response.status_code, 200) @@ -732,7 +883,7 @@ def test_get_id_token(self): @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 + Test that generation of ID Token does not work for anonymous user. """ self.client.logout() response = self.client.get(self.get_token_url) diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index b43c20be0089..da97f3010e0d 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -4,9 +4,9 @@ import json import logging from django.contrib.auth.decorators import login_required -from django.core.urlresolvers import reverse 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.locations import SlashSeparatedCourseKey from courseware.courses import get_course_with_access @@ -50,7 +50,9 @@ def edxnotes(request, course_id): } if not notes: - field_data_cache = FieldDataCache([course], course_key, request.user) + 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: @@ -93,6 +95,7 @@ def get_token(request, course_id): 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. diff --git a/lms/envs/common.py b/lms/envs/common.py index 7259d107099b..c072e45f4e6d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1962,3 +1962,6 @@ #date format the api will be formatting the datetime values API_DATE_FORMAT = '%Y-%m-%d' + +# FIXME: REMOVE BEFORE MERGE +OAUTH_ID_TOKEN_EXPIRATION = 60 * 60 * 24 diff --git a/lms/static/js/edxnotes/collections/notes.js b/lms/static/js/edxnotes/collections/notes.js index bbf50c9a2e1f..dfc7d06665ae 100644 --- a/lms/static/js/edxnotes/collections/notes.js +++ b/lms/static/js/edxnotes/collections/notes.js @@ -4,7 +4,41 @@ define([ 'backbone', 'js/edxnotes/models/note' ], function (Backbone, NoteModel) { var NotesCollection = Backbone.Collection.extend({ - model: NoteModel + 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; diff --git a/lms/static/js/edxnotes/models/note.js b/lms/static/js/edxnotes/models/note.js index 9ebabcb7314f..001f20c43d80 100644 --- a/lms/static/js/edxnotes/models/note.js +++ b/lms/static/js/edxnotes/models/note.js @@ -4,18 +4,30 @@ define(['backbone', 'underscore.string'], function (Backbone) { var NoteModel = Backbone.Model.extend({ defaults: { 'id': null, - 'created': null, - 'updated': null, - 'user': null, - 'usage_id': null, - 'course_id': null, - 'text': null, + 'created': '', + 'updated': '', + 'user': '', + 'usage_id': '', + 'course_id': '', + 'text': '', 'quote': '', + 'ranges': [], 'unit': { - 'display_name': null, - 'url': null + 'display_name': '', + 'url': '', + 'location': '' + }, + 'section': { + 'display_name': '', + 'location': '', + 'children': [] + }, + 'chapter': { + 'display_name': '', + 'location': '', + 'index': 0, + 'children': [] }, - 'ranges': [], // Flag indicating current state of the note: expanded or collapsed. 'is_expanded': false, // Flag indicating whether `More` link should be shown. diff --git a/lms/static/js/edxnotes/utils/template.js b/lms/static/js/edxnotes/utils/template.js new file mode 100644 index 000000000000..51b1bbdbf61e --- /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 000000000000..ca79ae134de8 --- /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 index 776d49903acb..6a9b3002f3c4 100644 --- a/lms/static/js/edxnotes/views/note_item.js +++ b/lms/static/js/edxnotes/views/note_item.js @@ -1,27 +1,20 @@ ;(function (define, undefined) { 'use strict'; define([ - 'jquery', 'backbone' -], function ($, Backbone) { + 'jquery', 'backbone', 'js/edxnotes/utils/template' +], function ($, Backbone, templateUtils) { var NoteItemView = Backbone.View.extend({ tagName: 'article', + className: 'note', id: function () { return 'note-' + _.uniqueId(); }, - className: 'note', events: { 'click .note-excerpt-more-link': 'moreHandler' }, initialize: function (options) { - var templateSelector = '#note-item-tpl', - templateText = $(templateSelector).text(); - - if (!templateText) { - console.error('Failed to load note-item template'); - } - - this.template = _.template(templateText); + this.template = templateUtils.loadTemplate('note-item'); this.listenTo(this.model, 'change:is_expanded', this.render); }, @@ -33,9 +26,9 @@ define([ }, getContext: function () { - return $.extend({}, this.model.attributes, { + return $.extend({ message: this.model.getNoteText() - }); + }, this.model.toJSON()); }, toggleNote: function () { diff --git a/lms/static/js/edxnotes/views/notes_page.js b/lms/static/js/edxnotes/views/notes_page.js index 893721bdae7c..9ff1403e62a4 100644 --- a/lms/static/js/edxnotes/views/notes_page.js +++ b/lms/static/js/edxnotes/views/notes_page.js @@ -2,20 +2,29 @@ 'use strict'; define([ 'backbone', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs_list', - 'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/search_results' + 'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/course_structure', + 'js/edxnotes/views/tabs/search_results' ], function ( - Backbone, TabsCollection, TabsListView, RecentActivityView, SearchResultsView + 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, diff --git a/lms/static/js/edxnotes/views/tab_item.js b/lms/static/js/edxnotes/views/tab_item.js index a8368bd1c7cb..6d1a1ff93137 100644 --- a/lms/static/js/edxnotes/views/tab_item.js +++ b/lms/static/js/edxnotes/views/tab_item.js @@ -1,7 +1,7 @@ ;(function (define, undefined) { 'use strict'; -define(['gettext', 'underscore', 'backbone'], -function (gettext, _, Backbone) { +define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'], +function (gettext, _, Backbone, templateUtils) { var TabItemView = Backbone.View.extend({ tagName: 'li', className: 'tab', @@ -14,14 +14,7 @@ function (gettext, _, Backbone) { }, initialize: function (options) { - var templateSelector = '#tab-item-tpl', - templateText = $(templateSelector).text(); - - if (!templateText) { - console.error('Failed to load tab-item template'); - } - - this.template = _.template(templateText); + this.template = templateUtils.loadTemplate('tab-item'); this.$el.attr('id', this.model.get('identifier')); this.listenTo(this.model, { 'change:is_active': function (model, value) { 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 000000000000..8ab9f2493784 --- /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 index c3a837d07b83..30591bf1c2ef 100644 --- a/lms/static/js/edxnotes/views/tab_view.js +++ b/lms/static/js/edxnotes/views/tab_view.js @@ -4,7 +4,7 @@ define([ 'underscore', 'backbone', 'js/edxnotes/models/tab' ], function (_, Backbone, TabModel) { var TabView = Backbone.View.extend({ - SubViewConstructor: null, + PanelConstructor: null, tabInfo: { name: '', @@ -63,12 +63,13 @@ define([ getSubView: function () { var collection = this.getCollection(); - return new this.SubViewConstructor({collection: collection}); + return new this.PanelConstructor({collection: collection}); }, destroySubView: function () { if (this.contentView) { this.contentView.remove(); + this.contentView = null; } }, 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 000000000000..ca9f401d0cf9 --- /dev/null +++ b/lms/static/js/edxnotes/views/tabs/course_structure.js @@ -0,0 +1,54 @@ +;(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: 'Course Structure', + + renderContent: function () { + var courseStructure = this.collection.getCourseStructure(); + _.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); + group.render().$el.appendTo(this.$el); + }, this); + + return this; + }, + + getGroup: function (chapter, section) { + var group = new NoteGroupView({ + chapter: chapter, + section: section + }); + this.children.push(group); + return group; + } + }), + + tabInfo: { + name: gettext('Course Structure'), + identifier: 'view-course-structure', + icon: 'icon-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 index 2289330bcb17..49a98c0e967e 100644 --- a/lms/static/js/edxnotes/views/tabs/recent_activity.js +++ b/lms/static/js/edxnotes/views/tabs/recent_activity.js @@ -1,30 +1,21 @@ ;(function (define, undefined) { 'use strict'; define([ - 'gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item', - 'js/edxnotes/views/tab_view', 'underscore.string' -], function (gettext, _, Backbone, NoteItemView, TabView) { + 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view' +], function (gettext, TabPanelView, TabView) { var RecentActivityView = TabView.extend({ - SubViewConstructor: Backbone.View.extend({ - tagName: 'section', - className: 'tab-panel', + PanelConstructor: TabPanelView.extend({ id: 'recent-panel', - render: function () { - var container = document.createDocumentFragment(); - container.appendChild(this.getTitle()); - this.collection.each(function (model) { - var item = new NoteItemView({model: model}); - container.appendChild(item.render().el); - }); - this.$el.html(container); - return this; + title: 'Recent Activity', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' ') }, - - getTitle: function () { - return $('

    ', { - 'class': 'sr', - 'text': gettext('Recent Activity') - }).get(0); + renderContent: function () { + this.$el.append(this.getNotes(this.collection.toArray())); + return this; } }), diff --git a/lms/static/js/edxnotes/views/tabs/search_results.js b/lms/static/js/edxnotes/views/tabs/search_results.js index 00142e66aece..6ca92c2d2fd8 100644 --- a/lms/static/js/edxnotes/views/tabs/search_results.js +++ b/lms/static/js/edxnotes/views/tabs/search_results.js @@ -1,27 +1,22 @@ ;(function (define, undefined) { 'use strict'; define([ - 'gettext', 'backbone', 'js/edxnotes/views/note_item', - 'js/edxnotes/views/tab_view', 'js/edxnotes/views/search_box', 'jquery.highlight' -], function (gettext, Backbone, NoteItemView, TabView, SearchBoxView) { + 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view', + 'js/edxnotes/views/search_box', 'jquery.highlight' +], function (gettext, TabPanelView, TabView, SearchBoxView) { var SearchResultsView = TabView.extend({ - SubViewConstructor: Backbone.View.extend({ - tagName: 'section', - className: 'tab-panel', + PanelConstructor: TabPanelView.extend({ id: 'search-results-panel', - attributes: { - 'tabindex': -1 + title: 'Search Results', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' ') }, highlightMatchedText: true, - render: function () { - var container = document.createDocumentFragment(); - container.appendChild(this.getTitle()); - this.collection.each(function (model) { - var item = new NoteItemView({model: model}); - container.appendChild(item.render().el); - }); - this.$el.html(container); - + renderContent: function () { + this.$el.append(this.getNotes(this.collection.toArray())); if (this.highlightMatchedText) { this.$('.note-comment-p').highlight(this.options.searchQuery, { element: 'span', @@ -31,28 +26,27 @@ define([ }); } return this; - }, - - getTitle: function () { - return $('

    ', { - 'class': 'sr', - 'text': gettext('Search Results') - }).get(0); } }), - NoResultsViewConstructor: Backbone.View.extend({ - tagName: 'section', - className: 'tab-panel', + NoResultsViewConstructor: TabPanelView.extend({ id: 'no-results-panel', - attributes: { - 'tabindex': -1 + title: 'No results found', + className: function () { + return [ + TabPanelView.prototype.className, + 'note-group' + ].join(' ') }, - render: function () { + renderContent: function () { var message = gettext('No results found for "%(query_string)s".'); - this.$el.html(interpolate(message, { - query_string: this.options.searchQuery - }, true)); + + this.$el.append($('

    ', { + text: interpolate(message, { + query_string: this.options.searchQuery + }, true) + })); + return this; } }), @@ -93,7 +87,7 @@ define([ var collection = this.getCollection(); if (collection) { if (collection.length) { - return new this.SubViewConstructor({ + return new this.PanelConstructor({ collection: collection, searchQuery: this.searchResults.searchQuery }); diff --git a/lms/static/js/spec/edxnotes/base64.js b/lms/static/js/spec/edxnotes/base64.js deleted file mode 100644 index 2931a7f50286..000000000000 --- a/lms/static/js/spec/edxnotes/base64.js +++ /dev/null @@ -1,54 +0,0 @@ -define([], function() { - 'use strict'; - var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", - base64Encode, makeToken; - - 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'; - }; - - return { - base64Encode: base64Encode, - makeToken: makeToken - } -}); 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 000000000000..f5dc0206a81a --- /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/helpers.js b/lms/static/js/spec/edxnotes/helpers.js new file mode 100644 index 000000000000..844d2e21cbe6 --- /dev/null +++ b/lms/static/js/spec/edxnotes/helpers.js @@ -0,0 +1,161 @@ +define(['underscore'], function(_) { + 'use strict'; + var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + LONG_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(''); + 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...' + ].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, + 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 index 85063773a6ca..c57c0f1b7b8b 100644 --- a/lms/static/js/spec/edxnotes/models/note_spec.js +++ b/lms/static/js/spec/edxnotes/models/note_spec.js @@ -1,26 +1,12 @@ -define(['js/edxnotes/collections/notes'], function(NotesCollection) { +define([ + 'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes' +], function(Helpers, NotesCollection) { 'use strict'; describe('EdxNotes NoteModel', function() { - var 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.', - 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...', - SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt'; - beforeEach(function () { this.collection = new NotesCollection([ - {quote: LONG_TEXT}, - {quote: SHORT_TEXT} + {quote: Helpers.LONG_TEXT}, + {quote: Helpers.SHORT_TEXT} ]); }); @@ -35,14 +21,14 @@ define(['js/edxnotes/collections/notes'], function(NotesCollection) { var model = this.collection.at(0); // is_expanded = false, show_link = true - expect(model.getNoteText()).toBe(TRUNCATED_TEXT); + expect(model.getNoteText()).toBe(Helpers.TRUNCATED_TEXT); model.set('is_expanded', true); // is_expanded = true, show_link = true - expect(model.getNoteText()).toBe(LONG_TEXT); + 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(LONG_TEXT); + expect(model.getNoteText()).toBe(Helpers.LONG_TEXT); }); }); }); diff --git a/lms/static/js/spec/edxnotes/views/note_item_spec.js b/lms/static/js/spec/edxnotes/views/note_item_spec.js index b4196e7d3f5a..874e8aeac41b 100644 --- a/lms/static/js/spec/edxnotes/views/note_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -1,33 +1,16 @@ define([ 'jquery', 'underscore', 'js/common_helpers/template_helpers', - 'js/edxnotes/models/note', 'js/edxnotes/views/note_item', - 'js/spec/edxnotes/custom_matchers' -], function($, _, TemplateHelpers, NoteModel, NoteItemView, customMatchers) { + 'js/spec/edxnotes/helpers', 'js/edxnotes/models/note', + 'js/edxnotes/views/note_item', 'js/spec/edxnotes/custom_matchers' +], function($, _, TemplateHelpers, Helpers, NoteModel, NoteItemView, customMatchers) { 'use strict'; describe('EdxNotes NoteItemView', function() { - var 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.', - 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...', - SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt', - getView; - - getView = function (model) { + var getView = function (model) { model = new NoteModel(_.defaults(model || {}, { created: 'December 11, 2014 at 11:12AM', updated: 'December 11, 2014 at 11:12AM', text: 'Third added model', - quote: LONG_TEXT + quote: Helpers.LONG_TEXT })); return new NoteItemView({model: model}).render(); @@ -35,24 +18,22 @@ define([ beforeEach(function() { customMatchers(this); - TemplateHelpers.installTemplates([ - 'templates/edxnotes/note-item' - ]); + TemplateHelpers.installTemplate('templates/edxnotes/note-item'); }); it('can be rendered properly', function() { var view = getView(); expect(view.$el).toContain('.note-excerpt-more-link'); - expect(view.$el).toContainText(TRUNCATED_TEXT); + expect(view.$el).toContainText(Helpers.TRUNCATED_TEXT); expect(view.$el).toContainText('More'); view.$('.note-excerpt-more-link').click(); - expect(view.$el).toContainText(LONG_TEXT); + expect(view.$el).toContainText(Helpers.LONG_TEXT); expect(view.$el).toContainText('(Show less)'); - view = getView({quote: SHORT_TEXT}); + view = getView({quote: Helpers.SHORT_TEXT}); expect(view.$el).not.toContain('.note-excerpt-more-link'); - expect(view.$el).toContainText(SHORT_TEXT); + expect(view.$el).toContainText(Helpers.SHORT_TEXT); }); it('should display update value and accompanying text', function() { diff --git a/lms/static/js/spec/edxnotes/views/notes_factory_spec.js b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js index 96f842542532..e297d9305d04 100644 --- a/lms/static/js/spec/edxnotes/views/notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js @@ -1,11 +1,9 @@ define([ 'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers', - 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/base64' -], function(Annotator, NotesFactory, AjaxHelpers, customMatchers, base64) { + 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' +], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) { 'use strict'; describe('EdxNotes NotesFactory', function() { - var wrapper; - beforeEach(function() { customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); @@ -18,7 +16,7 @@ define([ it('can initialize annotator correctly', function() { var requests = AjaxHelpers.requests(this), - token = base64.makeToken(), + token = Helpers.makeToken(), options = { user: 'a user', usage_id : 'an usage', diff --git a/lms/static/js/spec/edxnotes/views/notes_page_spec.js b/lms/static/js/spec/edxnotes/views/notes_page_spec.js index 261eebe9dcbd..28f18a814be9 100644 --- a/lms/static/js/spec/edxnotes/views/notes_page_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_page_spec.js @@ -1,47 +1,35 @@ define([ 'jquery', 'underscore', 'js/common_helpers/template_helpers', - 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/page_factory', - 'js/spec/edxnotes/custom_matchers' -], function($, _, TemplateHelpers, AjaxHelpers, NotesFactory, customMatchers) { + '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 = [ - { - 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' - } - ]; + var notes = Helpers.getDefaultNotes(); beforeEach(function() { customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ - 'templates/edxnotes/note-item', - 'templates/edxnotes/tab-item' + '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); + var requests = AjaxHelpers.requests(this), + tab; expect(this.view.$('#view-search-results')).not.toExist(); - expect(this.view.$('#view-recent-activity')).toHaveClass('is-active'); + 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'); @@ -52,6 +40,7 @@ define([ }); 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/tab_view_spec.js b/lms/static/js/spec/edxnotes/views/tab_view_spec.js index c2a73b642352..285ccc72abea 100644 --- a/lms/static/js/spec/edxnotes/views/tab_view_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -17,7 +17,7 @@ define([ } }), TestView = TabView.extend({ - SubViewConstructor: TestSubView, + PanelConstructor: TestSubView, tabInfo: { name: 'Test View Tab', is_closable: true 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 000000000000..82ce0af5dacd --- /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: 'Course Structure', + identifier: 'view-course-structure', + icon: 'icon-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 index c1fdd7a34644..bdcd6d77fc45 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js @@ -58,7 +58,7 @@ define([ var view = getView(this.collection, this.tabsCollection); expect(this.tabsCollection).toHaveLength(1); - expect(this.tabsCollection.at(0).attributes).toEqual({ + expect(this.tabsCollection.at(0).toJSON()).toEqual({ name: 'Recent Activity', identifier: 'view-recent-activity', icon: 'icon-time', 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 index 447db5c366f1..627156e9e5c6 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -73,7 +73,7 @@ define([ AjaxHelpers.respondWithJson(requests, responseJson); expect(this.tabsCollection).toHaveLength(1); - expect(this.tabsCollection.at(0).attributes).toEqual({ + expect(this.tabsCollection.at(0).toJSON()).toEqual({ name: 'Search Results', identifier: 'view-search-results', icon: 'icon-search', 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 index 9b682e6b6989..6f55ddc9f60b 100644 --- a/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js @@ -1,9 +1,10 @@ define([ 'jquery', 'annotator', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/visibility_decorator', - 'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/base64', - 'jasmine-jquery' + 'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/helpers', + 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' ], function( - $, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, customMatchers, base64 + $, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, Helpers, + customMatchers ) { 'use strict'; describe('EdxNotes ToggleNotesFactory', function() { @@ -12,7 +13,7 @@ define([ user: 'a user', usageId : 'an usage', courseId: 'a course', - token: base64.makeToken(), + token: Helpers.makeToken(), tokenUrl: '/test_token_url' }; diff --git a/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js index 9e57a9676933..ad63f78c4d6f 100644 --- a/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js +++ b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js @@ -1,7 +1,7 @@ define([ 'annotator', 'js/edxnotes/views/visibility_decorator', - 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/base64' -], function(Annotator, VisibilityDecorator, customMatchers, base64) { + 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' +], function(Annotator, VisibilityDecorator, Helpers, customMatchers) { 'use strict'; describe('EdxNotes VisibilityDecorator', function() { var params = { @@ -9,7 +9,7 @@ define([ user: 'a user', usageId : 'an usage', courseId: 'a course', - token: base64.makeToken(), + token: Helpers.makeToken(), tokenUrl: '/test_token_url' }; diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index a09dce3cafbb..958397615c0a 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -538,6 +538,7 @@ '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', diff --git a/lms/static/sass/course/_student-notes.scss b/lms/static/sass/course/_student-notes.scss index 4bc4163e2a09..b850c9e3b864 100644 --- a/lms/static/sass/course/_student-notes.scss +++ b/lms/static/sass/course/_student-notes.scss @@ -40,171 +40,178 @@ } } - .note { - @include clearfix(); - margin: 0; - padding: ($baseline*1.5) 0; - border-top: 1px solid $gray-l4; - border-bottom: none; + .note-group { - .wrapper-note-excerpts { - display: inline-block; - width: flex-grid(9, 12); - - .note-excerpt { - display: inline-block; - vertical-align: top; - background: $m-blue-l4; - - .note-excerpt-p, - .note-excerpt-ul, - .note-excerpt-ol { - @extend %t-copy-base; - position: relative; - padding: $baseline ($baseline*4) $baseline ($baseline*4); - } - } + &, .note-section { + border-top: 2px solid $gray-l4; + padding-top: $baseline; + } - .note-excerpt > .note-excerpt-p:before, - .note-excerpt > .note-excerpt-ul:before, - .note-excerpt > .note-excerpt-ol:before, - .note-excerpt > .note-excerpt-p:after, - .note-excerpt > .note-excerpt-ul:after, - .note-excerpt > .note-excerpt-ol:after { - @extend %t-title3; - display: block; - position: absolute; - height: 40px; - width: 40px; - font-family: "FontAwesome"; - // color: $gray-l4; - color: $white; - } + .course-title + .note-section { + border-top: none; + padding-top: 0; + } - .note-excerpt > .note-excerpt-p:before, - .note-excerpt > .note-excerpt-ul:before, - .note-excerpt > .note-excerpt-ol:before { - content: "\f10d"; - top: 0; - left: $baseline; - } + .course-title, .course-subtitle { + text-transform: uppercase; + letter-spacing: 1px; + } - .note-excerpt > .note-excerpt-p:after, - .note-excerpt > .note-excerpt-ul:after, - .note-excerpt > .note-excerpt-ol:after { - content: "\f10e"; - bottom: ($baseline/2); - right: $baseline; - } + .course-subtitle { + @extend %t-copy-sub1; + padding-bottom: $baseline; + font-weight: $font-light; + color: inherit; + } - .note-comments { - position: relative; - margin: 0; - padding: 0; - list-style: none; - // background: $m-blue-l4; - - .note-comment { - padding: ($baseline/2) $baseline; - border-bottom: 2px solid $white; - - .note-comment-p, - .note-comment-ul, - .note-comment-ol { - @extend %t-weight4; - padding: 0; - margin: 0; - background: transparent; - } + .note { + @include clearfix(); + margin: 0; + padding: ($baseline*1.5) 0; + border-top: 1px dotted $gray-l4; + border-bottom: none; - .note-comment-p { - overflow: hidden; - text-overflow: ellipsis; - } + &:first-child { // Not working, but don't know why. It's like it's not even being seen although it's in the code and in the proper order (last). + border-top: 0px; // Grrr + } // Grrr - .note-comment-ul, - .note-comment-ol { - padding: auto; - margin: auto; + .wrapper-note-excerpts { + display: inline-block; + width: flex-grid(9, 12); + + .note-excerpt { + display: inline-block; + vertical-align: top; + background: $m-blue-l4; + + .note-excerpt-p, + .note-excerpt-ul, + .note-excerpt-ol { + @extend %t-copy-base; + position: relative; + padding: $baseline ($baseline*4) $baseline ($baseline*4); } + } - .note-highlight { - background-color: #FFFF88; - } + .note-excerpt > .note-excerpt-p:before, + .note-excerpt > .note-excerpt-ul:before, + .note-excerpt > .note-excerpt-ol:before, + .note-excerpt > .note-excerpt-p:after, + .note-excerpt > .note-excerpt-ul:after, + .note-excerpt > .note-excerpt-ol:after { + @extend %t-title3; + display: block; + position: absolute; + height: 40px; + width: 40px; + font-family: "FontAwesome"; + color: $white; } - } - .note-comments:before { - @extend %t-title4; - display: block; - position: absolute; - top: -14px; - left: 20px; - height: 24px; - width: 24px; - font-family: "FontAwesome"; - line-height: 20px; - content: "\f0d8"; - // color: $m-blue-l4; - color: $white; - } - } + .note-excerpt > .note-excerpt-p:before, + .note-excerpt > .note-excerpt-ul:before, + .note-excerpt > .note-excerpt-ol:before { + content: "\f10d"; + top: 0; + left: $baseline; + } - .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-copy-sub1; - margin-top: $baseline; - text-transform: uppercase; - font-weight: $font-regular; - letter-spacing: 1px; - color: $gray-l2; + .note-excerpt > .note-excerpt-p:after, + .note-excerpt > .note-excerpt-ul:after, + .note-excerpt > .note-excerpt-ol:after { + content: "\f10e"; + bottom: ($baseline/2); + right: $baseline; } - .reference-title:first-child { - margin-top: 0; + .note-comments { + position: relative; + margin: 0; + padding: 0; + list-style: none; + + .note-comment { + padding: ($baseline/2) $baseline; + border-bottom: 2px solid $white; + + .note-comment-p, + .note-comment-ul, + .note-comment-ol { + @extend %t-weight4; + padding: 0; + margin: 0; + background: transparent; + } + + .note-comment-p { + overflow: hidden; + text-overflow: ellipsis; + } + + .note-comment-ul, + .note-comment-ol { + padding: auto; + margin: auto; + } + + .note-highlight { + background-color: #FFFF88; + } + } } - .reference-meta { - font-weight: $font-regular; - color: $m-gray-d2; + .note-comments:before { + @extend %t-title4; + display: block; + position: absolute; + top: -14px; + left: 20px; + height: 24px; + width: 24px; + font-family: "FontAwesome"; + line-height: 20px; + content: "\f0d8"; + color: $white; } + } - a.reference-meta { - color: $link-color; + .reference { + @extend %t-copy-sub1; + display: inline-block; + width: flex-grid(3, 12); + vertical-align: top; - &:hover, - &:focus { - color: $link-hover; + .wrapper-reference-content { + padding: 0 $baseline; + color: $gray-l2; + + .reference-title { + @extend %t-copy-sub1; + margin-top: $baseline; + text-transform: uppercase; + font-weight: $font-regular; + letter-spacing: 1px; + color: $gray-l2; } - } - } - } - } - .note-group { - padding-top: ($baseline*1.5); - border-top: 1px solid $gray-l4; + .reference-title:first-child { + margin-top: 0; + } - .group-lecture { - border-bottom: 2px solid $gray-l4; - padding-bottom: $baseline; - text-transform: uppercase; - letter-spacing: 1px; + .reference-meta { + font-weight: $font-regular; + color: $m-gray-d2; + } - .course-subtitle { - @extend %t-copy-sub1; - display: block; - font-weight: $font-light; - color: inherit; + a.reference-meta { + color: $link-color; + + &:hover, + &:focus { + color: $link-hover; + } + } + } } } } @@ -217,15 +224,21 @@ outline: none; } + .tab-panel.note-group { + padding-top: 0; + } + .inline-error { color: $red; border-bottom: 1px solid $red; padding: 0 0 0.5em; - margin-bottom: 1em; + margin: 1em 0; } .tab-list { @include clearfix(); + position: relative; + top: 2px; .tabs-label { @extend %t-copy-base; From 2c3f855c76d42638f2442d4d74fd821488eb6631 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 19 Dec 2014 14:38:33 -0500 Subject: [PATCH 11/47] Sass update for notes view --- lms/static/sass/course/_student-notes.scss | 55 ++-------------------- 1 file changed, 5 insertions(+), 50 deletions(-) diff --git a/lms/static/sass/course/_student-notes.scss b/lms/static/sass/course/_student-notes.scss index b850c9e3b864..c23b48c78c75 100644 --- a/lms/static/sass/course/_student-notes.scss +++ b/lms/static/sass/course/_student-notes.scss @@ -40,7 +40,7 @@ } } - .note-group { + .note-group { &, .note-section { border-top: 2px solid $gray-l4; @@ -71,9 +71,9 @@ border-top: 1px dotted $gray-l4; border-bottom: none; - &:first-child { // Not working, but don't know why. It's like it's not even being seen although it's in the code and in the proper order (last). - border-top: 0px; // Grrr - } // Grrr + &:first-of-type { + border-top: 0px; + } .wrapper-note-excerpts { display: inline-block; @@ -89,41 +89,10 @@ .note-excerpt-ol { @extend %t-copy-base; position: relative; - padding: $baseline ($baseline*4) $baseline ($baseline*4); + padding: $baseline; } } - .note-excerpt > .note-excerpt-p:before, - .note-excerpt > .note-excerpt-ul:before, - .note-excerpt > .note-excerpt-ol:before, - .note-excerpt > .note-excerpt-p:after, - .note-excerpt > .note-excerpt-ul:after, - .note-excerpt > .note-excerpt-ol:after { - @extend %t-title3; - display: block; - position: absolute; - height: 40px; - width: 40px; - font-family: "FontAwesome"; - color: $white; - } - - .note-excerpt > .note-excerpt-p:before, - .note-excerpt > .note-excerpt-ul:before, - .note-excerpt > .note-excerpt-ol:before { - content: "\f10d"; - top: 0; - left: $baseline; - } - - .note-excerpt > .note-excerpt-p:after, - .note-excerpt > .note-excerpt-ul:after, - .note-excerpt > .note-excerpt-ol:after { - content: "\f10e"; - bottom: ($baseline/2); - right: $baseline; - } - .note-comments { position: relative; margin: 0; @@ -159,20 +128,6 @@ } } } - - .note-comments:before { - @extend %t-title4; - display: block; - position: absolute; - top: -14px; - left: 20px; - height: 24px; - width: 24px; - font-family: "FontAwesome"; - line-height: 20px; - content: "\f0d8"; - color: $white; - } } .reference { From 119910a3f7e2b561596df0ce92376a8569d3ac46 Mon Sep 17 00:00:00 2001 From: polesye Date: Fri, 19 Dec 2014 12:33:02 +0200 Subject: [PATCH 12/47] Cleanup the code. --- CHANGELOG.rst | 20 +++++++++++++++++++ cms/djangoapps/contentstore/views/course.py | 1 - cms/djangoapps/contentstore/views/tabs.py | 1 - .../models/settings/course_metadata.py | 3 +-- common/lib/xmodule/xmodule/tabs.py | 1 - .../test/acceptance/pages/lms/courseware.py | 3 +++ .../tests/studio/test_studio_rerun.py | 2 +- lms/static/js/edxnotes/views/notes_page.js | 2 -- lms/static/js/edxnotes/views/page_factory.js | 4 ---- lms/static/js/edxnotes/views/search_box.js | 8 +------- .../js/edxnotes/views/tabs/search_results.js | 2 -- .../js/spec/edxnotes/views/search_box_spec.js | 8 +------- lms/templates/edxnotes/edxnotes.html | 5 +---- 13 files changed, 28 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 06fa45818e8d..09f6187f79b2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,26 @@ 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: 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/views/course.py b/cms/djangoapps/contentstore/views/course.py index 35b0ea076d39..11ca832bd2f2 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -911,7 +911,6 @@ def _config_course_advanced_components(request, course_module): """ # 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, diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index ce3aca9368fe..131be1d946b0 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -61,7 +61,6 @@ def tabs_handler(request, course_key_string): # present in the same order they are displayed in LMS tabs_to_render = [] - for tab in CourseTabList.iterate_displayable_cms( course_item, settings, diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 67fe113954b0..23320d1580d0 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -33,7 +33,6 @@ class CourseMetadata(object): 'tags', # from xblock 'visible_to_staff_only', 'group_access', - 'edxnotes_visibility', ] @classmethod @@ -48,7 +47,7 @@ def filtered_list(cls): if not settings.FEATURES.get('ENABLE_EXPORT_GIT'): filtered_list.append('giturl') - # Do not show edxnotes if feature is not enabled. + # Do not show edxnotes if the feature is disabled. if not settings.FEATURES.get('ENABLE_EDXNOTES'): filtered_list.append('edxnotes') diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index 9c853f9d8122..a905ba06a653 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -830,7 +830,6 @@ def iterate_displayable( yield item else: yield tab - instructor_tab = InstructorTab() if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): yield instructor_tab diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index deef319aa7be..510c19b9beca 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -61,6 +61,9 @@ def xblock_component_html_content(self, index=0): (default is 0) """ + # 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() diff --git a/common/test/acceptance/tests/studio/test_studio_rerun.py b/common/test/acceptance/tests/studio/test_studio_rerun.py index 72b4e0eb946f..a193fd46fc2b 100644 --- a/common/test/acceptance/tests/studio/test_studio_rerun.py +++ b/common/test/acceptance/tests/studio/test_studio_rerun.py @@ -101,4 +101,4 @@ def finished_processing(): courseware = CoursewarePage(self.browser, self.course_id) courseware.wait_for_page() self.assertEqual(courseware.num_xblock_components, 1) - self.assertEqual(self.COMPONENT_CONTENT, courseware.xblock_component_html_content()) + self.assertEqual(courseware.xblock_component_html_content(), self.COMPONENT_CONTENT) diff --git a/lms/static/js/edxnotes/views/notes_page.js b/lms/static/js/edxnotes/views/notes_page.js index 9ff1403e62a4..aa39ce513ab9 100644 --- a/lms/static/js/edxnotes/views/notes_page.js +++ b/lms/static/js/edxnotes/views/notes_page.js @@ -28,8 +28,6 @@ define([ this.searchResultsView = new SearchResultsView({ el: this.el, tabsCollection: this.tabsCollection, - user: this.options.user, - courseId: this.options.courseId, debug: this.options.debug, createTabOnInitialization: false }); diff --git a/lms/static/js/edxnotes/views/page_factory.js b/lms/static/js/edxnotes/views/page_factory.js index 156dea33d656..43ab4be3c367 100644 --- a/lms/static/js/edxnotes/views/page_factory.js +++ b/lms/static/js/edxnotes/views/page_factory.js @@ -8,8 +8,6 @@ define([ * @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.user User id of notes owner. - * @param {String} params.courseId Course id. * @param {String} params.endpoint The endpoint of the store. * @return {Object} An instance of NotesPageView. */ @@ -20,8 +18,6 @@ define([ el: $('.wrapper-student-notes').get(0), collection: collection, debug: params.debugMode, - user: params.user, - courseId: params.courseId, endpoint: params.endpoint }); }; diff --git a/lms/static/js/edxnotes/views/search_box.js b/lms/static/js/edxnotes/views/search_box.js index 0bdd893099cd..fbc1c752b492 100644 --- a/lms/static/js/edxnotes/views/search_box.js +++ b/lms/static/js/edxnotes/views/search_box.js @@ -138,19 +138,13 @@ define([ this.logger.log('sendRequest', { action: this.el.action, method: this.el.method, - user: this.options.user, - course_id: this.options.courseId, text: text }); return $.ajax({ url: this.el.action, type: this.el.method, dataType: 'json', - data: { - user: this.options.user, - course_id: this.options.courseId, - text: text - } + data: {text: text} }); } }); diff --git a/lms/static/js/edxnotes/views/tabs/search_results.js b/lms/static/js/edxnotes/views/tabs/search_results.js index 6ca92c2d2fd8..d3bc6877cab4 100644 --- a/lms/static/js/edxnotes/views/tabs/search_results.js +++ b/lms/static/js/edxnotes/views/tabs/search_results.js @@ -64,8 +64,6 @@ define([ this.searchResults = null; this.searchBox = new SearchBoxView({ el: document.getElementById('search-notes-form'), - user: this.options.user, - courseId: this.options.courseId, debug: this.options.debug, beforeSearchStart: this.onBeforeSearchStart, search: this.onSearch, diff --git a/lms/static/js/spec/edxnotes/views/search_box_spec.js b/lms/static/js/spec/edxnotes/views/search_box_spec.js index 912027e23bc5..ba19d98d5490 100644 --- a/lms/static/js/spec/edxnotes/views/search_box_spec.js +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -9,8 +9,6 @@ define([ getSearchBox = function (options) { options = _.defaults(options || {}, { el: $('#search-notes-form').get(0), - user: 'test_user', - courseId: 'test_course_id', beforeSearchStart: jasmine.createSpy(), search: jasmine.createSpy(), error: jasmine.createSpy(), @@ -51,11 +49,7 @@ define([ submitForm(this.searchBox, 'test_text'); request = requests[0]; expect(request.method).toBe(form.method.toUpperCase()); - expect(request.url).toBe(form.action + '?' + $.param({ - user: 'test_user', - course_id: 'test_course_id', - text: 'test_text' - })); + expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'})); }); it('returns success result', function () { diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 8ef87b29138c..2a523da68698 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -1,6 +1,5 @@ <%! from django.utils.translation import ugettext as _ %> <%! import json %> -<%! from student.models import anonymous_id_for_user %> <%namespace name='static' file='/static_content.html'/> <%inherit file="/main.html" /> @@ -85,9 +84,7 @@

    ${_('Start creating notes')}

    require(['js/edxnotes/views/page_factory'], function (NotesFactory) { var pageView = new NotesFactory({ notesList: ${notes}, - debugMode: ${debug}, - user: '${anonymous_id_for_user(user, None)}', - courseId: '${course.id}' + debugMode: ${debug} }); }); }).call(this, require || RequireJS.require); From 70515dcb77aa520ba65bf0b34082cd1ac70b6f06 Mon Sep 17 00:00:00 2001 From: polesye Date: Wed, 24 Dec 2014 15:26:16 +0200 Subject: [PATCH 13/47] Disable feature flag. --- cms/envs/common.py | 2 +- cms/envs/devstack.py | 2 -- cms/envs/test.py | 3 --- common/test/acceptance/tests/lms/test_lms_edxnotes.py | 3 +++ lms/envs/bok_choy.py | 5 +---- lms/envs/common.py | 4 ++-- lms/envs/devstack.py | 3 --- 7 files changed, 7 insertions(+), 15 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 660d26fdae7c..6d03b9ad24b7 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -118,7 +118,7 @@ 'IS_EDX_DOMAIN': False, # let students save and manage their annotations - 'ENABLE_EDXNOTES': True, + 'ENABLE_EDXNOTES': False, } ENABLE_JASMINE = False diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e2cee0384438..50fc0a1b37e2 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -85,5 +85,3 @@ ##################################################################### # Lastly, run any migrations, if needed. MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE) - -FEATURES['ENABLE_EDXNOTES'] = True diff --git a/cms/envs/test.py b/cms/envs/test.py index 221ebc4a0e11..8a10f9761f3a 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -231,6 +231,3 @@ FEATURES['ENABLE_CONTENT_LIBRARIES'] = True FEATURES['ENABLE_EDXNOTES'] = True -EDXNOTES_INTERFACE = { - 'url': 'http://localhost:8042/api/v1', -} diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 6e1318db8d86..64db23a56e1a 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -1,5 +1,7 @@ +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 @@ -9,6 +11,7 @@ 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. diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index f113f2651876..a4230207155b 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -67,10 +67,7 @@ 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', -} -FEATURES['ENABLE_EDXNOTES'] = True +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 c072e45f4e6d..7b6afa12da60 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -312,7 +312,7 @@ # Show the mobile app links in the footer 'ENABLE_FOOTER_MOBILE_APP_LINKS': False, - 'ENABLE_EDXNOTES': True, + 'ENABLE_EDXNOTES': False, } # Ignore static asset files on import which match this pattern @@ -918,7 +918,7 @@ # Configure the LMS to use our stub EdxNotes implementation EDXNOTES_INTERFACE = { - 'url': 'http://localhost:8042/api/v1', + 'url': 'http://example.com/api/v1', } ################################# Jasmine ################################## diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index aba19591575a..255d180e8b4d 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -95,9 +95,6 @@ FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True -################################ edX Student Notes ################################ -FEATURES['ENABLE_EDXNOTES'] = True - ##################################################################### # See if the developer has any local overrides. try: From 922a71e83cdc9e5eb842d5015ac43559787ee8e9 Mon Sep 17 00:00:00 2001 From: polesye Date: Wed, 24 Dec 2014 15:37:32 +0200 Subject: [PATCH 14/47] REVERT THIS COMMIT BEFORE MERGE. --- cms/envs/common.py | 2 +- common/test/acceptance/tests/lms/test_lms_edxnotes.py | 2 +- lms/envs/common.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 6d03b9ad24b7..660d26fdae7c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -118,7 +118,7 @@ 'IS_EDX_DOMAIN': False, # let students save and manage their annotations - 'ENABLE_EDXNOTES': False, + 'ENABLE_EDXNOTES': True, } ENABLE_JASMINE = False diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 64db23a56e1a..8116b6d0f5a2 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -11,7 +11,7 @@ from ...fixtures.edxnotes import EdxNotesFixture, Note, Range -@skipUnless(os.environ.get("FEATURE_EDXNOTES"), "Requires Student Notes feature to be enabled") +# @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. diff --git a/lms/envs/common.py b/lms/envs/common.py index 7b6afa12da60..33417cf998f1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -312,7 +312,7 @@ # Show the mobile app links in the footer 'ENABLE_FOOTER_MOBILE_APP_LINKS': False, - 'ENABLE_EDXNOTES': False, + 'ENABLE_EDXNOTES': True, } # Ignore static asset files on import which match this pattern From 123f6d40d9170a8de5e368019996dde3a3be2e61 Mon Sep 17 00:00:00 2001 From: Chris Rodriguez Date: Tue, 23 Dec 2014 12:39:52 -0500 Subject: [PATCH 15/47] Adding accessibility to tabs indicating active/inactive --- common/test/acceptance/pages/lms/edxnotes.py | 2 +- lms/static/js/edxnotes/views/tab_item.js | 8 ++++++++ lms/static/js/spec/edxnotes/views/tab_item_spec.js | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index 52ea477cf9ca..609b05dbf951 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -240,7 +240,7 @@ def tabs(self): """ tabs = self.q(css=".tabs .tab-label") if tabs: - return tabs.text + return map(lambda x: x.replace("Current tab\n", ""), tabs.text) else: return None diff --git a/lms/static/js/edxnotes/views/tab_item.js b/lms/static/js/edxnotes/views/tab_item.js index 6d1a1ff93137..7b1e759f2f2a 100644 --- a/lms/static/js/edxnotes/views/tab_item.js +++ b/lms/static/js/edxnotes/views/tab_item.js @@ -19,6 +19,14 @@ function (gettext, _, Backbone, templateUtils) { 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 }); diff --git a/lms/static/js/spec/edxnotes/views/tab_item_spec.js b/lms/static/js/spec/edxnotes/views/tab_item_spec.js index cd6c1809bfb4..9f2d872c1247 100644 --- a/lms/static/js/spec/edxnotes/views/tab_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_item_spec.js @@ -33,10 +33,14 @@ define([ 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 () { From 65ca5595309e5f3508806e7834fb71ed47b0152d Mon Sep 17 00:00:00 2001 From: polesye Date: Thu, 25 Dec 2014 17:24:17 +0200 Subject: [PATCH 16/47] TNL-1010: update final UI text strings. --- common/test/acceptance/pages/lms/edxnotes.py | 8 ++------ .../acceptance/tests/lms/test_lms_edxnotes.py | 18 ++++++------------ .../js/edxnotes/views/tabs/course_structure.js | 4 ++-- lms/static/js/fixtures/edxnotes/edxnotes.html | 4 ++-- .../views/tabs/course_structure_spec.js | 2 +- lms/templates/edxnotes/edxnotes.html | 4 ++-- lms/templates/edxnotes/note-item.underscore | 8 +------- 7 files changed, 16 insertions(+), 32 deletions(-) diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index 609b05dbf951..01e77966a825 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -102,14 +102,10 @@ def quote(self): def time_updated(self): return self._get_element_text(".reference-updated-date") - @property - def title_highlighted(self): - return self._get_element_text(".reference-title") - class EdxNotesPageView(PageObject): """ - Base class for EdxNotes views: Recent Activity, Course Structure, Search Results. + Base class for EdxNotes views: Recent Activity, Location in Course, Search Results. """ url = None BODY_SELECTOR = ".tab-panel" @@ -173,7 +169,7 @@ class RecentActivityView(EdxNotesPageView): class CourseStructureView(EdxNotesPageView): """ - Helper class for Course Structure view. + Helper class for Location in Course view. """ BODY_SELECTOR = "#structure-panel" TAB_SELECTOR = ".tab#view-course-structure" diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 8116b6d0f5a2..d5861dbdec25 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -321,12 +321,6 @@ def assertNoteContent(self, item, text=None, quote=None, unit_name=None, time_up self.assertIsNone(quote) self.assertEqual(unit_name, item.unit_name) self.assertEqual(time_updated, item.time_updated) - if text is not None and quote is not None: - self.assertEqual(item.title_highlighted, "HIGHLIGHTED & NOTED IN:") - elif text is not None: - self.assertEqual(item.title_highlighted, "HIGHLIGHTED IN:") - elif quote is not None: - self.assertEqual(item.title_highlighted, "NOTED IN:") def assertGroupContent(self, item, title=None, subtitles=None): self.assertEqual(item.title, title) @@ -399,10 +393,10 @@ def test_recent_activity_view(self): def test_course_structure_view(self): """ - Scenario: User can view all notes by course structure. + 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 "Course Structure" view + 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 """ @@ -492,7 +486,7 @@ def test_easy_access_from_notes_page(self): 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 "Course Structure" view + 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 @@ -552,12 +546,12 @@ 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 "Course Structure" tabs + 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 "Course Structure" tab + 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 @@ -571,7 +565,7 @@ def test_tabs_behaves_correctly(self): # We're on Recent Activity tab. self.assertEqual(len(self.notes_page.tabs), 2) - self.assertEqual([u"Recent Activity", u"Course Structure"], self.notes_page.tabs) + 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) diff --git a/lms/static/js/edxnotes/views/tabs/course_structure.js b/lms/static/js/edxnotes/views/tabs/course_structure.js index ca9f401d0cf9..8aa997c15303 100644 --- a/lms/static/js/edxnotes/views/tabs/course_structure.js +++ b/lms/static/js/edxnotes/views/tabs/course_structure.js @@ -7,7 +7,7 @@ define([ var CourseStructureView = TabView.extend({ PanelConstructor: TabPanelView.extend({ id: 'structure-panel', - title: 'Course Structure', + title: 'Location in Course', renderContent: function () { var courseStructure = this.collection.getCourseStructure(); @@ -43,7 +43,7 @@ define([ }), tabInfo: { - name: gettext('Course Structure'), + name: gettext('Location in Course'), identifier: 'view-course-structure', icon: 'icon-list-ul' } diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html index 6701d54d64d5..d75adc5dc17e 100644 --- a/lms/static/js/fixtures/edxnotes/edxnotes.html +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -3,13 +3,13 @@

    Notes - Highlights and personal notes you've made within the course + Highlights and notes you've made in course content

    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 index 82ce0af5dacd..d5f8e7e98ab9 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js @@ -52,7 +52,7 @@ define([ expect(this.tabsCollection).toHaveLength(1); expect(this.tabsCollection.at(0).toJSON()).toEqual({ - name: 'Course Structure', + name: 'Location in Course', identifier: 'view-course-structure', icon: 'icon-list-ul', is_active: true, diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 2a523da68698..931337afbdf4 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -14,13 +14,13 @@
    -

    ${_('Notes')} ${_("Highlights and personal notes you've made within the course")}

    +

    ${_('Notes')} ${_("Highlights and notes you've made in course content")}

    % if notes: diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore index f6bc8d3d2183..a488d2cab747 100644 --- a/lms/templates/edxnotes/note-item.underscore +++ b/lms/templates/edxnotes/note-item.underscore @@ -22,13 +22,7 @@
    - <% if (text && quote) { %> -

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

    - <% } else if (text) { %> -

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

    - <% } else if (quote) { %> -

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

    - <% } %> +

    <%- gettext("Location of highlight or note:") %>

    <%- unit.display_name %>

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

    <%- updated %> From a65c2f889eeca3ecd7015a659c0e7e3a866c729a Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 22 Dec 2014 11:35:30 +0200 Subject: [PATCH 17/47] TNL-930: Turn off Student Notes when HAT is enabled. --- .../xmodule/modulestore/inheritance.py | 9 +++--- lms/djangoapps/edxnotes/decorators.py | 6 ++-- lms/djangoapps/edxnotes/helpers.py | 20 +++++++++++-- lms/djangoapps/edxnotes/tests.py | 30 +++++++++++++++++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 19e0945a00c3..ad3c5eb2c00e 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -173,14 +173,15 @@ class InheritanceMixin(XBlockMixin): default=default_reset_button ) edxnotes = Boolean( - display_name=_("Enable Notes"), - help=_("Enter true or false. If true, you can use the Notes for HTML components."), + 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=_("Enable visibility of Notes"), - help=_("Enter true or false. If true, Notes for HTML components will be visible."), + 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 ) diff --git a/lms/djangoapps/edxnotes/decorators.py b/lms/djangoapps/edxnotes/decorators.py index a9eb466ef841..aadeff8a1258 100644 --- a/lms/djangoapps/edxnotes/decorators.py +++ b/lms/djangoapps/edxnotes/decorators.py @@ -26,8 +26,10 @@ def get_html(self, *args, **kwargs): 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 or depends on the feature flag/advanced - # settings of the course. + # 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: diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 21015249800f..b69517cfea2a 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -323,13 +323,27 @@ def generate_uid(): def is_feature_enabled(course): """ - Returns True if the edxnotes app is enabled for the course, False otherwise. + Returns True if Student Notes feature is enabled for the course, + False otherwise. - In order for the app to be enabled it must be: + 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. """ tab_found = next((True for t in course.tabs if t["type"] == "edxnotes"), False) feature_enabled = settings.FEATURES.get("ENABLE_EDXNOTES") - return feature_enabled and tab_found + return (feature_enabled and 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 index 5f9918152bbd..9e5f7b485d90 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -104,7 +104,7 @@ def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_t @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_edxnotes_disabled_if_edxnotes_flag_is_false(self): """ - Tests if get_html is wrapped when feature flag is on, but edxnotes are + 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()) @@ -112,17 +112,25 @@ def test_edxnotes_disabled_if_edxnotes_flag_is_false(self): @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_disabled(self): """ - Tests if get_html is not wrapped when feature flag is off. + 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 if get_html is not wrapped when problem is rendered in Studio. + 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): @@ -179,6 +187,22 @@ def test_edxnotes_not_enabled(self): 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 From 8dd870406770ad853ed462e129a4dc7019588a1f Mon Sep 17 00:00:00 2001 From: Tim Babych Date: Mon, 29 Dec 2014 17:27:01 +0200 Subject: [PATCH 18/47] Addressing comments, updating docstrings --- .../tests/test_course_settings.py | 3 +- cms/djangoapps/contentstore/views/course.py | 130 ++++++++---------- common/lib/xmodule/xmodule/edxnotes_utils.py | 2 +- common/lib/xmodule/xmodule/tests/test_tabs.py | 4 +- common/test/acceptance/pages/lms/edxnotes.py | 2 +- .../acceptance/tests/lms/test_lms_edxnotes.py | 4 +- lms/djangoapps/edxnotes/helpers.py | 37 ++--- lms/djangoapps/edxnotes/tests.py | 14 +- lms/djangoapps/edxnotes/views.py | 8 +- lms/envs/common.py | 7 +- lms/static/js/edxnotes/views/search_box.js | 4 +- .../js/edxnotes/views/toggle_notes_factory.js | 2 +- lms/static/js/fixtures/edxnotes/edxnotes.html | 2 +- .../js/spec/edxnotes/views/note_item_spec.js | 2 +- .../js/spec/edxnotes/views/search_box_spec.js | 6 +- .../views/toggle_notes_factory_spec.js | 2 +- lms/templates/edxnotes/edxnotes.html | 17 +-- lms/templates/edxnotes/note-item.underscore | 2 +- 18 files changed, 113 insertions(+), 135 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 0a613a93d722..df34ee26883c 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -615,7 +615,7 @@ def test_update_from_json_filtered_edxnotes_on(self): @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False}) def test_update_from_json_filtered_edxnotes_off(self): """ - If feature flag is on, then edxnotes must not be updated. + If feature flag is off, then edxnotes must not be updated. """ test_model = CourseMetadata.update_from_json( self.course, @@ -785,6 +785,7 @@ def test_advanced_components_munge_tabs(self): 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. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 11ca832bd2f2..f8b4de47818a 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -897,87 +897,70 @@ def _remove_tab(request, tab_type, course_module): return False -# pylint: disable=invalid-name -def _config_course_advanced_components(request, course_module): - """ - Check to see if the user instantiated any advanced components. This - is a hack that does the following : - 1) adds/removes the open ended panel tab to a course automatically - if the user has indicated that they want to edit the - combinedopendended or peergrading module - 2) adds/removes the notes panel tab to a course automatically if - the user has indicated that they want the notes module enabled in - their course - """ - # TODO refactor the above into distinct advanced policy settings - filter_tabs = True # Exceptional conditions will pull this to False - if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components - tab_component_map = { - 'open_ended': OPEN_ENDED_COMPONENT_TYPES, - 'notes': NOTE_COMPONENT_TYPES, - } - # Check to see if the user instantiated any notes or open ended components - for tab_type in tab_component_map.keys(): - component_types = tab_component_map.get(tab_type) - found_ac_type = False - for ac_type in component_types: - # Check if the user has incorrectly failed to put the value in an iterable. - new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value'] - if hasattr(new_advanced_component_list, '__iter__'): - if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES: - if _add_tab(request, tab_type, course_module): - # Set this flag to avoid the tab removal code below. - filter_tabs = False - found_ac_type = True # break - else: - # If not iterable, return immediately and let validation handle. - return +def is_advanced_component_present(request, advanced_components): + """ + Return True when one of `advanced_components` is present in the request. + + raises TypeError + when request.ADVANCED_COMPONENT_POLICY_KEY is malformed (not iterable) + """ + if ADVANCED_COMPONENT_POLICY_KEY not in request.json: + return False + + new_advanced_component_list = request.json[ADVANCED_COMPONENT_POLICY_KEY]['value'] + for ac_type in advanced_components: + if ac_type in new_advanced_component_list and ac_type in ADVANCED_COMPONENT_TYPES: + return True - # If we did not find a module type in the advanced settings, - # we may need to remove the tab from the course. - if not found_ac_type: # Remove tab from the course if needed - if _remove_tab(request, tab_type, course_module): - # Indicate that tabs should *not* be filtered out of - # the metadata - filter_tabs = False - return filter_tabs +def is_field_value_true(request, field_list): + """ + Return True when one of field values is set to True by request + """ + return any([request.json.get(field, {}).get('value') for field in field_list]) # pylint: disable=invalid-name -def _config_course_settings(request, course_module, filter_tabs=True): +def _modify_tabs_to_components(request, course_module): """ - Check to see if the user enabled some advanced settings (boolean). - This is a hack that does the following : - 1) adds/removes the edx notes panel tab to a course automatically if - the user has indicated that they want the notes module enabled in - their course + 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 = { - 'edxnotes': ['edxnotes'] + # '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']) } - # Check to see if the user instantiated any notes or open ended components + + tabs_changed = False for tab_type in tab_component_map.keys(): - if tab_type in request.json: - component_types = tab_component_map.get(tab_type) - found_ac_type = False - for ac_type in component_types: - field_value = request.json[ac_type]['value'] - if field_value is True: - if _add_tab(request, ac_type, course_module): - # Set this flag to avoid the tab removal code below. - filter_tabs = False - found_ac_type = True # break - - # 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 - if _remove_tab(request, ac_type, course_module): - # Indicate that tabs should *not* be filtered out of - # the metadata - filter_tabs = False - - return filter_tabs + 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 @@ -1009,9 +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) - filter_tabs = _config_course_settings(request, course_module, filter_tabs) + # 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/common/lib/xmodule/xmodule/edxnotes_utils.py b/common/lib/xmodule/xmodule/edxnotes_utils.py index a041f610212b..70324d653997 100644 --- a/common/lib/xmodule/xmodule/edxnotes_utils.py +++ b/common/lib/xmodule/xmodule/edxnotes_utils.py @@ -6,7 +6,7 @@ def edxnotes(cls): """ - Conditional decorator that loads edxnotes only when they are exist. + 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 diff --git a/common/lib/xmodule/xmodule/tests/test_tabs.py b/common/lib/xmodule/xmodule/tests/test_tabs.py index 3a2d7384bfc1..f912d41495df 100644 --- a/common/lib/xmodule/xmodule/tests/test_tabs.py +++ b/common/lib/xmodule/xmodule/tests/test_tabs.py @@ -431,7 +431,7 @@ def check_edxnotes_tab(self): def test_edxnotes_tabs_enabled(self): """ - Test that check if edxnotes tab can be enabled correctly. + Tests that edxnotes tab is shown when feature is enabled. """ self.settings.FEATURES['ENABLE_EDXNOTES'] = True tab = self.check_edxnotes_tab() @@ -439,7 +439,7 @@ def test_edxnotes_tabs_enabled(self): def test_edxnotes_tabs_disabled(self): """ - Test that check if edxnotes tab doewn't work when feature is disabled. + Tests that edxnotes tab is not shown when feature is disabled. """ self.settings.FEATURES['ENABLE_EDXNOTES'] = False tab = self.check_edxnotes_tab() diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index 01e77966a825..81a09bdcd6d9 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -189,7 +189,7 @@ class EdxNotesPage(CoursePage): """ EdxNotes page. """ - url_path = "edxnotes" + url_path = "edxnotes/" MAPPING = { "recent": RecentActivityView, "structure": CourseStructureView, diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index d5861dbdec25..44a59a7b3b80 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -519,7 +519,7 @@ def test_search_behaves_correctly(self): 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 "Search field cannot be blank." + 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 @@ -531,7 +531,7 @@ def test_search_behaves_correctly(self): self.notes_page.search(" ") # Displays error message self.assertTrue(self.notes_page.is_error_visible) - self.assertEqual(self.notes_page.error_text, u"Search field cannot be blank.") + 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 diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index b69517cfea2a..31b4c941cf8d 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -24,6 +24,7 @@ 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__) @@ -68,7 +69,7 @@ def get_token_url(course_id): Returns token url for the course. """ return reverse("get_token", kwargs={ - "course_id": course_id.to_deprecated_string(), + "course_id": unicode(course_id), }) @@ -106,9 +107,6 @@ def get_parent_unit(xblock): Find vertical that is a unit, not just some container. """ while xblock: - xblock = xblock.get_parent() - if xblock is None: - return None parent = xblock.get_parent() if parent is None: return None @@ -118,12 +116,15 @@ def get_parent_unit(xblock): def preprocess_collection(user, course, collection): """ - Reprocess provided `collection(list)`: adds information about ancestor, - converts "updated" date, sorts the collection in descending order. + 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() @@ -141,7 +142,10 @@ def preprocess_collection(user, course, collection): filtered_collection.append(model) continue - usage_key = course.id.make_usage_key_from_deprecated_string(usage_id) + 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: @@ -203,22 +207,22 @@ def get_module_context(course, item): Returns dispay_name and url for the parent module. """ item_dict = { - 'location': item.location.to_deprecated_string(), + 'location': unicode(item.location), 'display_name': item.display_name_with_default, } if item.category == 'chapter' and item.get_parent(): course = item.get_parent() - ancestor_children = [child.to_deprecated_string() for child in course.children] + ancestor_children = [unicode(child) for child in course.children] item_dict['index'] = ancestor_children.index(item_dict['location']) elif item.category == 'vertical': item_dict['url'] = reverse("jump_to_id", kwargs={ - "course_id": course.id.to_deprecated_string(), + "course_id": unicode(course.id), "module_id": item.url_name, }) if item.category in ('chapter', 'sequential'): - item_dict['children'] = [child.to_deprecated_string() for child in item.children] + item_dict['children'] = [unicode(child) for child in item.children] return item_dict @@ -260,7 +264,7 @@ def get_notes(user, course): def get_endpoint(path=""): """ - Returns endpoint. + Returns edx-notes-api endpoint. """ try: url = settings.EDXNOTES_INTERFACE['url'] @@ -288,7 +292,7 @@ def get_course_position(course_module): If there is no current position in the course or chapter, then selects the first child. """ - urlargs = {'course_id': course_module.id.to_deprecated_string()} + 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") @@ -331,10 +335,9 @@ def is_feature_enabled(course): 2) present in the course tab configuration. 3) Harvard Annotation Tool must be disabled for the course. """ - tab_found = next((True for t in course.tabs if t["type"] == "edxnotes"), False) - feature_enabled = settings.FEATURES.get("ENABLE_EDXNOTES") - - return (feature_enabled and tab_found) and not is_harvard_notes_enabled(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): diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 9e5f7b485d90..704566d6452e 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -175,7 +175,7 @@ def _get_jump_to_url(self, vertical): Returns `jump_to_id` url for the `vertical`. """ return reverse("jump_to_id", kwargs={ - "course_id": self.course.id.to_deprecated_string(), + "course_id": unicode(self.course.id), "module_id": vertical.url_name, }) @@ -720,9 +720,7 @@ 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() - mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) - mock_course_module.position = 3 + mock_course_module = MagicMock(id=self.course.id, position=3) mock_chapter = MagicMock() mock_chapter.url_name = 'chapter_url_name' @@ -739,9 +737,7 @@ def test_get_course_position_no_section(self): """ Returns `None` if no section found. """ - mock_course_module = MagicMock() - mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) - mock_course_module.position = None + 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)) @@ -750,9 +746,7 @@ 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() - mock_course_module.id.to_deprecated_string.return_value = unicode(self.course.id) - mock_course_module.position = None + mock_course_module = MagicMock(id=self.course.id, position=None) mock_chapter = MagicMock() mock_chapter.url_name = 'chapter_url_name' diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index da97f3010e0d..90d1d0213d47 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from edxmako.shortcuts import render_to_response -from opaque_keys.edx.locations import SlashSeparatedCourseKey +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 @@ -30,7 +30,7 @@ def edxnotes(request, course_id): """ Displays the EdxNotes page. """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course_key = CourseKey.from_string(course_id) course = get_course_with_access(request.user, "load", course_key) if not is_feature_enabled(course): @@ -68,7 +68,7 @@ def search_notes(request, course_id): """ Handles search requests. """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course_key = CourseKey.from_string(course_id) course = get_course_with_access(request.user, "load", course_key) if not is_feature_enabled(course): @@ -100,7 +100,7 @@ def edxnotes_visibility(request, course_id): """ Handle ajax call from "Show notes" checkbox. """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + 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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 33417cf998f1..bd4297293713 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -918,7 +918,7 @@ # Configure the LMS to use our stub EdxNotes implementation EDXNOTES_INTERFACE = { - 'url': 'http://example.com/api/v1', + 'url': 'http://localhost:8120/api/v1', } ################################# Jasmine ################################## @@ -1963,5 +1963,6 @@ #date format the api will be formatting the datetime values API_DATE_FORMAT = '%Y-%m-%d' -# FIXME: REMOVE BEFORE MERGE -OAUTH_ID_TOKEN_EXPIRATION = 60 * 60 * 24 +# 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/static/js/edxnotes/views/search_box.js b/lms/static/js/edxnotes/views/search_box.js index fbc1c752b492..43244631201c 100644 --- a/lms/static/js/edxnotes/views/search_box.js +++ b/lms/static/js/edxnotes/views/search_box.js @@ -9,8 +9,8 @@ define([ 'submit': 'submitHandler' }, - errorMessage: gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'), - emptyFieldMessage: gettext('Search field cannot be blank.'), + errorMessage: gettext('An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.'), + emptyFieldMessage: gettext('Please enter a term in the search field.'), initialize: function (options) { _.bindAll(this, 'onSuccess', 'onError', 'onComplete'); diff --git a/lms/static/js/edxnotes/views/toggle_notes_factory.js b/lms/static/js/edxnotes/views/toggle_notes_factory.js index 048c7424d19d..751aa644d341 100644 --- a/lms/static/js/edxnotes/views/toggle_notes_factory.js +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -8,7 +8,7 @@ define([ 'click .action-toggle-notes': 'toogleHandler' }, - errorMessage: gettext('Cannot save your state. This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'), + errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."), initialize: function (options) { this.visibility = options.visibility; diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html index d75adc5dc17e..3a47f0eee892 100644 --- a/lms/static/js/fixtures/edxnotes/edxnotes.html +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -21,7 +21,7 @@

    View notes by:

    -

    Loading...

    +

    Loading

    diff --git a/lms/static/js/spec/edxnotes/views/note_item_spec.js b/lms/static/js/spec/edxnotes/views/note_item_spec.js index 874e8aeac41b..95ab6c1919e4 100644 --- a/lms/static/js/spec/edxnotes/views/note_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -29,7 +29,7 @@ define([ view.$('.note-excerpt-more-link').click(); expect(view.$el).toContainText(Helpers.LONG_TEXT); - expect(view.$el).toContainText('(Show less)'); + expect(view.$el).toContainText('(Less)'); view = getView({quote: Helpers.SHORT_TEXT}); expect(view.$el).not.toContain('.note-excerpt-more-link'); diff --git a/lms/static/js/spec/edxnotes/views/search_box_spec.js b/lms/static/js/spec/edxnotes/views/search_box_spec.js index ba19d98d5490..11d1f2ae26e4 100644 --- a/lms/static/js/spec/edxnotes/views/search_box_spec.js +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -77,7 +77,7 @@ define([ submitForm(this.searchBox, 'test_text'); AjaxHelpers.respondWithJson(requests, {}); expect(this.searchBox.options.error).toHaveBeenCalledWith( - 'This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.', + '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( @@ -90,7 +90,7 @@ define([ submitForm(this.searchBox, 'test_text'); AjaxHelpers.respondWithError(requests); expect(this.searchBox.options.error).toHaveBeenCalledWith( - 'This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.', + '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( @@ -139,7 +139,7 @@ define([ expect(requests).toHaveLength(0); assertBoxIsEnabled(this.searchBox); expect(this.searchBox.options.error).toHaveBeenCalledWith( - 'Search field cannot be blank.', + 'Please enter a term in the search field.', ' ' ); }); 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 index 6f55ddc9f60b..9d313b140a35 100644 --- a/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js @@ -74,7 +74,7 @@ define([ this.button.click(); AjaxHelpers.respondWithError(requests); expect(errorContainer).toContainText( - 'Cannot save your state. This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.' + "An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page." ); this.button.click(); diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 931337afbdf4..ac19e6fd3882 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -34,20 +34,17 @@

    ${_('View notes by:')}

    % if notes:
    -

    ${_("Loading...")}

    +

    ${_("Loading")}

    % else:

    ${_('You have not made any notes in this course yet.')}

    -

    ${_('Lorem ipsum dolor sit amet, consectetur 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.')}

    - -

    ${_('Other Students In This Course Are Using Notes To:')}

    +

    ${_('Other students in this course are using notes to:')}

      -
    • ${_('Add study notes to course content for exams and homework.')}
    • -
    • ${_('Highlight important concepts for later coursework or future courses')}
    • -
    • ${_('Donec id elit non mi porta gravida et eget metus.')}
    • -
    • ${_('Praesent commodo cursus magna, vel scelerisque nisl consectetur et.')}
    • +
    • ${_("Mark a passage or concept so that it's easy to find later.")}
    • +
    • ${_('Record thoughts about a specific passage or concept.')}
    • +
    • ${_('Highlight important information to review later in the course or in future courses.')}
    % if position is not None: @@ -81,8 +78,8 @@

    ${_('Start creating notes')}

    % if notes: From 71d8f86dc2d96a7e69a96fbcefe9c63a029f2a18 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Sun, 28 Dec 2014 17:52:32 -0500 Subject: [PATCH 20/47] LMS: adding/organizing utility-based navigation styling --- lms/static/sass/course.scss.mako | 1 + .../sass/course/layout/_calculator.scss | 9 +-- lms/static/sass/elements/_navigation.scss | 62 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 lms/static/sass/elements/_navigation.scss diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index d60c33c72e6b..3cba2db8ae94 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -27,6 +27,7 @@ // base - elements @import 'elements/typography'; @import 'elements/controls'; +@import 'elements/navigation'; // all archetypes of navigation // Course base / layout styles @import 'course/layout/courseware_header'; diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index c873b4cd7cc6..886a5745d6ef 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -5,7 +5,6 @@ div.calc-main { @include transition(bottom $tmg-avg linear 0s); -webkit-appearance: none; width: 100%; - z-index: 99; &.open { bottom: -36px; @@ -13,7 +12,8 @@ div.calc-main { a.calc { @include text-hide(); - background: url("../images/calc-icon.png") rgba(#111, .9) no-repeat center; + @include transition(background-color $tmg-f2 ease-in-out 0s); + background: url("../images/calc-icon.png") $black-t3 no-repeat center; border-bottom: 0; border-radius: 3px 3px 0 0; color: $white; @@ -27,17 +27,18 @@ div.calc-main { width: 16px; &:hover, &:focus { - opacity: 0.8; + background-color: $black; } &.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/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss new file mode 100644 index 000000000000..48820534db0a --- /dev/null +++ b/lms/static/sass/elements/_navigation.scss @@ -0,0 +1,62 @@ +// LMS -- elements -- navigation +// ==================== + +// in this document: +// -------------------- +// +notes +// +skip navigation +// +utility navigation + +// +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*2.25); + bottom: 0; + + + .wrapper-utility { + @extend %wipe-last-child; + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/2); + } + + .utility-control { + @include transition(background-color $tmg-f2 ease-in-out 0s); + position: relative; + bottom: -6px; // needed to sync up with current rogue/more complex calc utility alignment + display: inline-block; + vertical-align: middle; + border-radius: ($baseline/10); + padding: ($baseline/2) ($baseline*0.75) ($baseline*0.75) ($baseline*0.75); + background: $black-t3; + color: $white; + + // STATE: hover/active + &:hover, &:active { + background: $black; + } + + // STATE: is active/in use + &.is-active { + background: red; + } + } +} From c12fd51852ecfe8ac0de88cf19e8913ddd85121d Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Sun, 28 Dec 2014 19:19:14 -0500 Subject: [PATCH 21/47] LMS: revising course styling compile + (adding student-notes module) --- lms/static/sass/_developer.scss | 6 ----- lms/static/sass/course-rtl.scss.mako | 27 ++++++++++--------- lms/static/sass/course.scss.mako | 26 +++++++++--------- .../{layout => modules}/_calculator.scss | 3 +++ .../course/{layout => modules}/_chat.scss | 4 +-- .../sass/course/modules/_student-notes.scss | 27 +++++++++++++++++++ .../course/{layout => modules}/_timer.scss | 3 +++ 7 files changed, 62 insertions(+), 34 deletions(-) rename lms/static/sass/course/{layout => modules}/_calculator.scss (98%) rename lms/static/sass/course/{layout => modules}/_chat.scss (93%) create mode 100644 lms/static/sass/course/modules/_student-notes.scss rename lms/static/sass/course/{layout => modules}/_timer.scss (91%) diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index 4ef7aa9e1ae1..586a7fcd0b83 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -117,9 +117,3 @@ padding-left: ($baseline/4); } } - -.edx-notes-visibility { - .error { - color: $red; - } -} diff --git a/lms/static/sass/course-rtl.scss.mako b/lms/static/sass/course-rtl.scss.mako index 1937a13eaeca..e9d4d2930aa9 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"; @@ -68,11 +69,11 @@ @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 3cba2db8ae94..0fc53f0adff5 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -29,35 +29,35 @@ @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"; @@ -69,11 +69,11 @@ @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/layout/_calculator.scss b/lms/static/sass/course/modules/_calculator.scss similarity index 98% rename from lms/static/sass/course/layout/_calculator.scss rename to lms/static/sass/course/modules/_calculator.scss index 886a5745d6ef..5d7a09aad69c 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; 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 b9724ef4b448..eb5bc4e5c647 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 000000000000..c084fceee7d3 --- /dev/null +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -0,0 +1,27 @@ +// LMS -- modules -- student notes +// ==================== + +// in this document: +// -------------------- +// +notes +// +messages +// +creating/editing notes +// +listing notes + +// +notes: +// -------------------- +// this Sass partial contains all of the styling needed for the in-line student notes UI. + +// +messages +// -------------------- + +// CASE: error in toggling notes +.edx-notes-visibility { + +} + +// +creating/editing notes +// -------------------- + +// +listing notes +// -------------------- 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 dfff695c97df..84bbb8c5681e 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; From 8baf4ea1e9190345c7238648eb036dad00050d77 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Sun, 28 Dec 2014 19:34:17 -0500 Subject: [PATCH 22/47] LMS: styling student notes error/alert --- .../sass/course/modules/_student-notes.scss | 45 +++++++++++++++++-- lms/static/sass/elements/_navigation.scss | 2 +- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lms/static/sass/course/modules/_student-notes.scss b/lms/static/sass/course/modules/_student-notes.scss index c084fceee7d3..727ed461f3b6 100644 --- a/lms/static/sass/course/modules/_student-notes.scss +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -4,7 +4,7 @@ // in this document: // -------------------- // +notes -// +messages +// +toggling notes // +creating/editing notes // +listing notes @@ -12,12 +12,51 @@ // -------------------- // this Sass partial contains all of the styling needed for the in-line student notes UI. -// +messages +// +toggling notes // -------------------- -// CASE: error in 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; + margin-right: -($baseline/4); + 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; + background: $black-t3; + padding: ($baseline/4) $baseline; +} + +// vendor customization +.annotator-notice-error { + border-color: $error-color; } // +creating/editing notes diff --git a/lms/static/sass/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss index 48820534db0a..de5dccc9b0f6 100644 --- a/lms/static/sass/elements/_navigation.scss +++ b/lms/static/sass/elements/_navigation.scss @@ -34,7 +34,7 @@ .wrapper-utility { @extend %wipe-last-child; display: inline-block; - vertical-align: middle; + vertical-align: bottom; margin-right: ($baseline/2); } From 0ad9b24555bceb937d77478027247bc8672b62f5 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 29 Dec 2014 09:03:06 -0500 Subject: [PATCH 23/47] LMS: customizing annotator vendor styling/UI - error message --- lms/static/sass/course/modules/_student-notes.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lms/static/sass/course/modules/_student-notes.scss b/lms/static/sass/course/modules/_student-notes.scss index 727ed461f3b6..2b1c9bb760ce 100644 --- a/lms/static/sass/course/modules/_student-notes.scss +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -54,6 +54,15 @@ padding: ($baseline/4) $baseline; } +// CASE: annotator error in toggling notes +// vendor customization +.annotator-notice { + @extend %t-weight4; + @extend %t-copy-sub1; + background: $gray-d4; + padding: ($baseline/2) $baseline; +} + // vendor customization .annotator-notice-error { border-color: $error-color; From bdd920b49881a3fe72249fcda35a485db18f53d9 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 29 Dec 2014 13:32:31 -0500 Subject: [PATCH 24/47] LMS: adding in active state styling/markup for utility nav --- lms/static/sass/base/_variables.scss | 1 + lms/static/sass/elements/_navigation.scss | 2 +- lms/templates/edxnotes/toggle_notes.html | 7 ++++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 025591b4e6b9..69f4df0491f3 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -329,6 +329,7 @@ $header-graphic-sub-color: $m-gray-d2; $error-color: $error-red; $warning-color: $m-pink; $confirm-color: $m-green; +$active-color: $blue; // Notifications $notify-banner-bg-1: rgb(56,56,56); diff --git a/lms/static/sass/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss index de5dccc9b0f6..0a5362aacf11 100644 --- a/lms/static/sass/elements/_navigation.scss +++ b/lms/static/sass/elements/_navigation.scss @@ -56,7 +56,7 @@ // STATE: is active/in use &.is-active { - background: red; + background: $active-color; } } } diff --git a/lms/templates/edxnotes/toggle_notes.html b/lms/templates/edxnotes/toggle_notes.html index 3ab00b814923..66d774023305 100644 --- a/lms/templates/edxnotes/toggle_notes.html +++ b/lms/templates/edxnotes/toggle_notes.html @@ -9,11 +9,12 @@ %> From 34f1bdd0769c1c619927bf5a029013a16b6f0a91 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 29 Dec 2014 23:06:52 +0200 Subject: [PATCH 25/47] Add stateful classes. --- lms/static/coffee/src/calculator.coffee | 2 +- .../js/edxnotes/views/toggle_notes_factory.js | 7 +- .../js/fixtures/edxnotes/toggle_notes.html | 2 +- .../views/toggle_notes_factory_spec.js | 6 + .../sass/course/modules/_calculator.scss | 7 +- .../sass/course/modules/_student-notes.scss | 4 +- lms/static/sass/elements/_navigation.scss | 20 +- .../calculator/toggle_calculator.html | 242 ++++++++++-------- lms/templates/chat/toggle_chat.html | 4 +- lms/templates/edxnotes/toggle_notes.html | 5 +- 10 files changed, 171 insertions(+), 128 deletions(-) diff --git a/lms/static/coffee/src/calculator.coffee b/lms/static/coffee/src/calculator.coffee index 230ff5e92259..60fe3936fd29 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/js/edxnotes/views/toggle_notes_factory.js b/lms/static/js/edxnotes/views/toggle_notes_factory.js index 751aa644d341..391e2ee5975a 100644 --- a/lms/static/js/edxnotes/views/toggle_notes_factory.js +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -14,7 +14,8 @@ define([ this.visibility = options.visibility; this.visibilityUrl = options.visibilityUrl; this.checkboxIcon = this.$('.checkbox-icon'); - this.$('.action-toggle-notes').removeClass('is-disabled'); + this.actionLink = this.$('.action-toggle-notes'); + this.actionLink.removeClass('is-disabled'); }, toogleHandler: function (event) { @@ -28,17 +29,21 @@ define([ if (this.visibility) { _.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote); this.checkboxIcon.removeClass('icon-check-empty').addClass('icon-check'); + this.actionLink.addClass('is-active'); } else { EdxnotesVisibilityDecorator.disableNotes(); this.checkboxIcon.removeClass('icon-check').addClass('icon-check-empty'); + this.actionLink.removeClass('is-active'); } }, hideErrorMessage: function() { + this.$el.removeClass('has-error'); this.$('.edx-notes-visibility-error').text(''); }, showErrorMessage: function(message) { + this.$el.addClass('has-error'); this.$('.edx-notes-visibility-error').text(message); }, diff --git a/lms/static/js/fixtures/edxnotes/toggle_notes.html b/lms/static/js/fixtures/edxnotes/toggle_notes.html index 2f0c9a80040b..d20cae412e58 100644 --- a/lms/static/js/fixtures/edxnotes/toggle_notes.html +++ b/lms/static/js/fixtures/edxnotes/toggle_notes.html @@ -1,5 +1,5 @@
    - + Show notes 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 index 9d313b140a35..8378623c1ee5 100644 --- a/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/toggle_notes_factory_spec.js @@ -45,10 +45,12 @@ define([ expect(this.button).not.toHaveClass('is-disabled'); expect(this.icon).toHaveClass('icon-check'); expect(this.icon).not.toHaveClass('icon-check-empty'); + expect(this.button).toHaveClass('is-active'); this.button.click(); expect(this.icon).toHaveClass('icon-check-empty'); expect(this.icon).not.toHaveClass('icon-check'); + expect(this.button).not.toHaveClass('is-active'); expect(Annotator._instances).toHaveLength(0); AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', { @@ -59,6 +61,7 @@ define([ this.button.click(); expect(this.icon).toHaveClass('icon-check'); expect(this.icon).not.toHaveClass('icon-check-empty'); + expect(this.button).toHaveClass('is-active'); expect(Annotator._instances).toHaveLength(2); AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', { @@ -71,15 +74,18 @@ define([ var requests = AjaxHelpers.requests(this), errorContainer = $('.edx-notes-visibility-error'); + expect(this.toggleNotes.$el).not.toHaveClass('has-error'); 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(this.toggleNotes.$el).toHaveClass('has-error'); this.button.click(); AjaxHelpers.respondWithJson(requests, {}); expect(errorContainer).toBeEmpty(); + expect(this.toggleNotes.$el).not.toHaveClass('has-error'); }); }); }); diff --git a/lms/static/sass/course/modules/_calculator.scss b/lms/static/sass/course/modules/_calculator.scss index 5d7a09aad69c..3473055e72dd 100644 --- a/lms/static/sass/course/modules/_calculator.scss +++ b/lms/static/sass/course/modules/_calculator.scss @@ -13,8 +13,7 @@ div.calc-main { bottom: -36px; } - a.calc { - @include text-hide(); + .calc { @include transition(background-color $tmg-f2 ease-in-out 0s); background: url("../images/calc-icon.png") $black-t3 no-repeat center; border-bottom: 0; @@ -26,8 +25,8 @@ div.calc-main { margin-right: ($baseline/2); padding: 8px 12px; position: relative; - top: -45px; - width: 16px; + top: -42px; + width: ($baseline*0.75); &:hover, &:focus { background-color: $black; diff --git a/lms/static/sass/course/modules/_student-notes.scss b/lms/static/sass/course/modules/_student-notes.scss index 2b1c9bb760ce..666967c6a2bd 100644 --- a/lms/static/sass/course/modules/_student-notes.scss +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -25,8 +25,8 @@ max-width: ($baseline*15); display: none; vertical-align: bottom; - margin-right: -($baseline/4); - border-right: ($baseline/4) solid $error-color; + @include margin-right(-($baseline/4)); + @include border-right(($baseline/4) solid $error-color); padding: ($baseline/2) $baseline; background: $black-t3; text-align: center; diff --git a/lms/static/sass/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss index 0a5362aacf11..629965e72cf9 100644 --- a/lms/static/sass/elements/_navigation.scss +++ b/lms/static/sass/elements/_navigation.scss @@ -27,15 +27,14 @@ .nav-utilities { @extend %ui-depth3; position: fixed; - right: ($baseline*2.25); + @include right($baseline*2.25); bottom: 0; - .wrapper-utility { @extend %wipe-last-child; display: inline-block; vertical-align: bottom; - margin-right: ($baseline/2); + @include margin-right($baseline/2); } .utility-control { @@ -59,4 +58,19 @@ background: $active-color; } } + + // 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; + + // STATE: hover/active + &:hover, &:active, &:focus { + border: none; + box-shadow: none; + } + } } diff --git a/lms/templates/calculator/toggle_calculator.html b/lms/templates/calculator/toggle_calculator.html index f5cece16bf9c..d7dd18da1daf 100644 --- a/lms/templates/calculator/toggle_calculator.html +++ b/lms/templates/calculator/toggle_calculator.html @@ -2,123 +2,143 @@ <%! from django.core.urlresolvers import reverse %>
    - + -
    -
    -
    - +
    + +
    + -
    -

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

    +
    +

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

    - ${_("Hints")} + ${_("Hints")} -
    +
  • + + + + + + + + + + + + + + + ## Translators: Please do not translate mathematical symbols. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ## Translators: Please see http://en.wikipedia.org/wiki/Scientific_notation + + ## Translators: 10^ is a mathematical symbol. Please do not translate. + + + + + ## Translators: this is part of scientific notation. Please see http://en.wikipedia.org/wiki/Scientific_notation#E_notation + + ## Translators: 1e is a mathematical symbol. Please do not translate. + + + + +
    ${_("To Use")}${_("Type")}${_("Examples")}
    ${_("Numbers")}${_("Integers")}
    + ${_("Fractions")}
    + ${_("Decimals")} +
    2520
    + 2/3
    + 3.14, .98 +
    ${_("Operators")}${_("+ - * / (add, subtract, multiply, divide)")}
    + ## Translators: Please do not translate mathematical symbols. + ${_("^ (raise to a power)")}
    + ## Translators: Please do not translate mathematical symbols. + ${_("_ (add a subscript)")}
    + ## Translators: Please do not translate mathematical symbols. + ${_("|| (parallel resistors)")} +
    x+(2*y)/x-1 + x^(n+1)
    + v_IN+v_OUT
    + 1||2 +
    ${_("Greek letters")}${_("Name of letter")}alpha
    + lambda +
    ${_("Constants")}c, e, g, i, j, k, pi, q, T20*c
    + 418*T +
    ${_("Affixes")}${_("Percent sign (%) and metric affixes (d, c, m, u, n, p, k, M, G, T)")}20%
    + 20c
    + 418T +
    ${_("Basic functions")}abs, exp, fact or factorial, ln, log2, log10, sqrtabs(x+y)
    + sqrt(x^2-y) +
    ${_("Trigonometric functions")}sin, cos, tan, sec, csc, cot
    + arcsin, sinh, arcsinh, etc.
    +
    sin(4x+y)
    + arccsch(4x+y) +
    ${_("Scientific notation")}${_("10^ and the exponent")}10^-9
    ${_("e notation")}${_("1e and the exponent")}1e-9
    +
  • +
    +
    - - - -
    + + + +
    diff --git a/lms/templates/chat/toggle_chat.html b/lms/templates/chat/toggle_chat.html index 203d5277423c..390dbd5c07bc 100644 --- a/lms/templates/chat/toggle_chat.html +++ b/lms/templates/chat/toggle_chat.html @@ -3,8 +3,8 @@
    - Open Chat - Close Chat + ${_('Open Chat')} + ${_('Close Chat')}
    ## The Candy.js plugin wants to render in an element with #candy diff --git a/lms/templates/edxnotes/toggle_notes.html b/lms/templates/edxnotes/toggle_notes.html index 66d774023305..9a836e7d967c 100644 --- a/lms/templates/edxnotes/toggle_notes.html +++ b/lms/templates/edxnotes/toggle_notes.html @@ -8,15 +8,14 @@ edxnotes_visibility_url = reverse("edxnotes_visibility", kwargs={"course_id": course.id}) %> % endif diff --git a/lms/templates/edxnotes/note-item.underscore b/lms/templates/edxnotes/note-item.underscore index c1e9c89ba7e2..0ecdd2913596 100644 --- a/lms/templates/edxnotes/note-item.underscore +++ b/lms/templates/edxnotes/note-item.underscore @@ -1,29 +1,33 @@
    <% if (message) { %> -
    -

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

    -
    +
    +

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

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

      <%= text %>

      -
    2. -
    +
      +
    1. +

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

      +

      <%= text %>

      +
    2. +
    <% } %>
    +
    -

    <%- gettext("Location of highlight or note:") %>

    +

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

    <%- unit.display_name %> +

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

    <%- updated %>
    diff --git a/lms/templates/edxnotes/tab-item.underscore b/lms/templates/edxnotes/tab-item.underscore index 0f1e6eaa230c..93612aca0dfd 100644 --- a/lms/templates/edxnotes/tab-item.underscore +++ b/lms/templates/edxnotes/tab-item.underscore @@ -1,5 +1,13 @@ <% var hasIcon = icon ? 1 : 0; %> -<% if (hasIcon) { %> <% } %><%- gettext(name) %><% if (is_closable) { %> - x + + + <% if (hasIcon) { %> <% } %><%- gettext(name) %> + + +<% if (is_closable) { %> + + + <%- gettext("Clear search results") %> + <% } %> From 0c5503457826257c253a1a8aed529cfa54908cb6 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 5 Jan 2015 12:38:32 +0200 Subject: [PATCH 31/47] Fix unit, jasmine and bok-choy tests. --- common/test/acceptance/pages/lms/edxnotes.py | 2 +- .../test/acceptance/tests/lms/test_lms_edxnotes.py | 14 +++++++------- lms/djangoapps/edxnotes/tests.py | 2 +- lms/static/js/edxnotes/views/tab_view.js | 7 ++++--- lms/static/js/fixtures/edxnotes/edxnotes.html | 9 ++++++++- .../js/spec/edxnotes/views/note_item_spec.js | 2 +- lms/static/js/spec/edxnotes/views/tab_view_spec.js | 8 ++++---- .../edxnotes/views/tabs/course_structure_spec.js | 2 +- .../edxnotes/views/tabs/recent_activity_spec.js | 2 +- .../edxnotes/views/tabs/search_results_spec.js | 10 +++++----- 10 files changed, 33 insertions(+), 25 deletions(-) diff --git a/common/test/acceptance/pages/lms/edxnotes.py b/common/test/acceptance/pages/lms/edxnotes.py index d4cb460b9460..e4c6cadaffc9 100644 --- a/common/test/acceptance/pages/lms/edxnotes.py +++ b/common/test/acceptance/pages/lms/edxnotes.py @@ -92,7 +92,7 @@ def unit_name(self): @property def text(self): - return self._get_element_text(".note-comments") + return self._get_element_text(".note-comment-p") @property def quote(self): diff --git a/common/test/acceptance/tests/lms/test_lms_edxnotes.py b/common/test/acceptance/tests/lms/test_lms_edxnotes.py index 44a59a7b3b80..61d761a66c6a 100644 --- a/common/test/acceptance/tests/lms/test_lms_edxnotes.py +++ b/common/test/acceptance/tests/lms/test_lms_edxnotes.py @@ -412,13 +412,13 @@ def test_course_structure_view(self): self.assertGroupContent( groups[0], - title=u"TEST SECTION 1", - subtitles=[u"TEST SUBSECTION 1", u"TEST SUBSECTION 2"] + title=u"Test Section 1", + subtitles=[u"Test Subsection 1", u"Test Subsection 2"] ) self.assertSectionContent( sections[0], - title=u"TEST SUBSECTION 1", + title=u"Test Subsection 1", notes=[u"Fifth note", u"Third note", None] ) @@ -447,7 +447,7 @@ def test_course_structure_view(self): self.assertSectionContent( sections[1], - title=u"TEST SUBSECTION 2", + title=u"Test Subsection 2", notes=[u"Fourth note"] ) @@ -460,13 +460,13 @@ def test_course_structure_view(self): self.assertGroupContent( groups[1], - title=u"TEST SECTION 2", - subtitles=[u"TEST SUBSECTION 3"], + title=u"Test Section 2", + subtitles=[u"Test Subsection 3"], ) self.assertSectionContent( sections[2], - title=u"TEST SUBSECTION 3", + title=u"Test Subsection 3", notes=[u"First note"] ) diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 704566d6452e..4b0b39691be7 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -797,7 +797,7 @@ def test_edxnotes_view_is_enabled(self, mock_get_notes): """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_page_url) - self.assertContains(response, '

    Notes') + 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): diff --git a/lms/static/js/edxnotes/views/tab_view.js b/lms/static/js/edxnotes/views/tab_view.js index 06b49854185d..9527b23841f2 100644 --- a/lms/static/js/edxnotes/views/tab_view.js +++ b/lms/static/js/edxnotes/views/tab_view.js @@ -115,6 +115,7 @@ define([ this.$('.wrapper-msg') .removeClass('is-hidden') .find('.msg-content .copy').text(message) + .closest('.msg') .focus(); }, @@ -122,9 +123,9 @@ define([ * Hides error message. */ hideErrorMessage: function () { - this.$('.inline-error') - .text('') - .addClass('is-hidden'); + this.$('.wrapper-msg') + .addClass('is-hidden') + .find('.msg-content .copy').text(''); } }); diff --git a/lms/static/js/fixtures/edxnotes/edxnotes.html b/lms/static/js/fixtures/edxnotes/edxnotes.html index dd8f497b5a56..a50268a199db 100644 --- a/lms/static/js/fixtures/edxnotes/edxnotes.html +++ b/lms/static/js/fixtures/edxnotes/edxnotes.html @@ -15,11 +15,18 @@

    + +
    -

    Loading

    diff --git a/lms/static/js/spec/edxnotes/views/note_item_spec.js b/lms/static/js/spec/edxnotes/views/note_item_spec.js index 95ab6c1919e4..dd818fac2e68 100644 --- a/lms/static/js/spec/edxnotes/views/note_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -29,7 +29,7 @@ define([ view.$('.note-excerpt-more-link').click(); expect(view.$el).toContainText(Helpers.LONG_TEXT); - expect(view.$el).toContainText('(Less)'); + expect(view.$el).toContainText('Less'); view = getView({quote: Helpers.SHORT_TEXT}); expect(view.$el).not.toContain('.note-excerpt-more-link'); diff --git a/lms/static/js/spec/edxnotes/views/tab_view_spec.js b/lms/static/js/spec/edxnotes/views/tab_view_spec.js index 4ab2bb5f01b3..0a48f86e1ce3 100644 --- a/lms/static/js/spec/edxnotes/views/tab_view_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -95,15 +95,15 @@ define([ it('can show/hide error messages', function () { var view = getView(this.tabsCollection), - errorHolder = view.$('.inline-error'); + errorHolder = view.$('.wrapper-msg'); view.showErrorMessage('

    error message is here

    '); expect(errorHolder).not.toHaveClass('is-hidden'); - expect(errorHolder).toBeFocused(); - expect(errorHolder).toContainText('

    error message is here

    '); + expect(errorHolder.find('.msg')).toBeFocused(); + expect(errorHolder.find('.copy')).toContainText('

    error message is here

    '); view.hideErrorMessage(); expect(errorHolder).toHaveClass('is-hidden'); - expect(errorHolder).toBeEmpty(); + 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 index d5f8e7e98ab9..a9a8971b51c6 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js @@ -54,7 +54,7 @@ define([ expect(this.tabsCollection.at(0).toJSON()).toEqual({ name: 'Location in Course', identifier: 'view-course-structure', - icon: 'icon-list-ul', + icon: 'fa fa-list-ul', is_active: true, is_closable: false }); 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 index bdcd6d77fc45..8134539e8ccd 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js @@ -61,7 +61,7 @@ define([ expect(this.tabsCollection.at(0).toJSON()).toEqual({ name: 'Recent Activity', identifier: 'view-recent-activity', - icon: 'icon-time', + icon: 'fa fa-clock-o', is_active: true, is_closable: false }); 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 index 627156e9e5c6..87ac532a2598 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -76,7 +76,7 @@ define([ expect(this.tabsCollection.at(0).toJSON()).toEqual({ name: 'Search Results', identifier: 'view-search-results', - icon: 'icon-search', + icon: 'fa fa-search', is_active: true, is_closable: true }); @@ -167,16 +167,16 @@ define([ }) ); - expect(view.$('.inline-error')).not.toHaveClass('is-hidden'); - expect(view.$('.inline-error')).toContainText('test error message'); + expect(view.$('.wrapper-msg')).not.toHaveClass('is-hidden'); + expect(view.$('.wrapper-msg .copy')).toContainText('test error message'); expect(view.$('.note-highlight')).not.toExist(); expect(view.$('.ui-loading')).toHaveClass('is-hidden'); submitForm(view.searchBox, 'Second'); AjaxHelpers.respondWithJson(requests, responseJson); - expect(view.$('.inline-error')).toHaveClass('is-hidden'); - expect(view.$('.inline-error')).toBeEmpty(); + expect(view.$('.wrapper-msg')).toHaveClass('is-hidden'); + expect(view.$('.wrapper-msg .copy')).toBeEmpty(); expect(view.$('.note-highlight')).toExist(); }); From 027a64f79a183620e2cd7f7170e09a1d7464ac94 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 5 Jan 2015 18:02:57 +0200 Subject: [PATCH 32/47] TNL-1089: Fix search error on tab switching. --- lms/static/js/edxnotes/views/tab_view.js | 8 +++++++- lms/static/js/spec/edxnotes/views/tab_view_spec.js | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lms/static/js/edxnotes/views/tab_view.js b/lms/static/js/edxnotes/views/tab_view.js index 9527b23841f2..4d213d06115b 100644 --- a/lms/static/js/edxnotes/views/tab_view.js +++ b/lms/static/js/edxnotes/views/tab_view.js @@ -48,7 +48,7 @@ define([ * Renders content for the view. */ render: function () { - this.showLoadingIndicator(); + this.hideErrorMessage().showLoadingIndicator(); // If the view is already rendered, destroy it. this.destroySubView(); this.renderContent().always(this.hideLoadingIndicator); @@ -98,6 +98,7 @@ define([ */ showLoadingIndicator: function() { this.getLoadingIndicator().removeClass('is-hidden'); + return this; }, /** @@ -105,6 +106,7 @@ define([ */ hideLoadingIndicator: function() { this.getLoadingIndicator().addClass('is-hidden'); + return this; }, @@ -117,6 +119,8 @@ define([ .find('.msg-content .copy').text(message) .closest('.msg') .focus(); + + return this; }, /** @@ -126,6 +130,8 @@ define([ this.$('.wrapper-msg') .addClass('is-hidden') .find('.msg-content .copy').text(''); + + return this; } }); diff --git a/lms/static/js/spec/edxnotes/views/tab_view_spec.js b/lms/static/js/spec/edxnotes/views/tab_view_spec.js index 0a48f86e1ce3..2134a71eecf0 100644 --- a/lms/static/js/spec/edxnotes/views/tab_view_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -105,5 +105,14 @@ define([ 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(); + }); }); }); From bd9b0c9b1eaebc3b537df467dce1a9f412214c4f Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 8 Jan 2015 13:14:08 -0500 Subject: [PATCH 33/47] LMS: more revising icon syntax for edX notes UI --- lms/templates/edxnotes/tab-item.underscore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/edxnotes/tab-item.underscore b/lms/templates/edxnotes/tab-item.underscore index 93612aca0dfd..5a836d4ecd75 100644 --- a/lms/templates/edxnotes/tab-item.underscore +++ b/lms/templates/edxnotes/tab-item.underscore @@ -6,7 +6,7 @@ <% if (is_closable) { %> - + <%- gettext("Clear search results") %> <% } %> From e35f75851593110295115e30dd8046d789427362 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 8 Jan 2015 13:19:14 -0500 Subject: [PATCH 34/47] LMS: correcting lost calculator toggle styling from previous commits --- lms/static/sass/course/modules/_calculator.scss | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lms/static/sass/course/modules/_calculator.scss b/lms/static/sass/course/modules/_calculator.scss index 3473055e72dd..70369dd60529 100644 --- a/lms/static/sass/course/modules/_calculator.scss +++ b/lms/static/sass/course/modules/_calculator.scss @@ -17,13 +17,12 @@ div.calc-main { @include transition(background-color $tmg-f2 ease-in-out 0s); background: url("../images/calc-icon.png") $black-t3 no-repeat center; border-bottom: 0; - border-radius: 3px 3px 0 0; + border-radius: ($baseline/10); color: $white; float: right; - height: 20px; - display: inline-block; + height: $baseline; margin-right: ($baseline/2); - padding: 8px 12px; + padding: $baseline; position: relative; top: -42px; width: ($baseline*0.75); From bf0f6448d6c2f3fe901806b89b9025c9ad300b32 Mon Sep 17 00:00:00 2001 From: polesye Date: Sat, 27 Dec 2014 09:13:53 +0200 Subject: [PATCH 35/47] TNL-973: Highlighting. --- common/static/js/vendor/jquery.highlight.js | 108 ------------------ lms/djangoapps/edxnotes/helpers.py | 13 ++- lms/djangoapps/edxnotes/tests.py | 7 +- .../edxnotes/views/tabs/course_structure.js | 8 +- .../js/edxnotes/views/tabs/search_results.js | 17 +-- .../views/tabs/search_results_spec.js | 7 -- lms/static/js/spec/main.js | 5 - lms/static/js_test.yml | 1 - lms/static/require-config-lms.js | 5 - lms/static/sass/course/_student-notes.scss | 4 + 10 files changed, 27 insertions(+), 148 deletions(-) delete mode 100644 common/static/js/vendor/jquery.highlight.js diff --git a/common/static/js/vendor/jquery.highlight.js b/common/static/js/vendor/jquery.highlight.js deleted file mode 100644 index 9dcf3c7af3ff..000000000000 --- a/common/static/js/vendor/jquery.highlight.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * jQuery Highlight plugin - * - * Based on highlight v3 by Johann Burkard - * http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html - * - * Code a little bit refactored and cleaned (in my humble opinion). - * Most important changes: - * - has an option to highlight only entire words (wordsOnly - false by default), - * - has an option to be case sensitive (caseSensitive - false by default) - * - highlight element tag and class names can be specified in options - * - * Usage: - * // wrap every occurrance of text 'lorem' in content - * // with (default options) - * $('#content').highlight('lorem'); - * - * // search for and highlight more terms at once - * // so you can save some time on traversing DOM - * $('#content').highlight(['lorem', 'ipsum']); - * $('#content').highlight('lorem ipsum'); - * - * // search only for entire word 'lorem' - * $('#content').highlight('lorem', { wordsOnly: true }); - * - * // don't ignore case during search of term 'lorem' - * $('#content').highlight('lorem', { caseSensitive: true }); - * - * // wrap every occurrance of term 'ipsum' in content - * // with - * $('#content').highlight('ipsum', { element: 'em', className: 'important' }); - * - * // remove default highlight - * $('#content').unhighlight(); - * - * // remove custom highlight - * $('#content').unhighlight({ element: 'em', className: 'important' }); - * - * - * Copyright (c) 2009 Bartek Szopka - * - * Licensed under MIT license. - * - */ - -jQuery.extend({ - highlight: function (node, re, nodeName, className) { - if (node.nodeType === 3) { - var match = node.data.match(re); - if (match) { - var highlight = document.createElement(nodeName || 'span'); - highlight.className = className || 'highlight'; - var wordNode = node.splitText(match.index); - wordNode.splitText(match[0].length); - var wordClone = wordNode.cloneNode(true); - highlight.appendChild(wordClone); - wordNode.parentNode.replaceChild(highlight, wordNode); - return 1; //skip added node in parent - } - } else if ((node.nodeType === 1 && node.childNodes) && // only element nodes that have children - !/(script|style)/i.test(node.tagName) && // ignore script and style nodes - !(node.tagName === nodeName.toUpperCase() && node.className === className)) { // skip if already highlighted - for (var i = 0; i < node.childNodes.length; i++) { - i += jQuery.highlight(node.childNodes[i], re, nodeName, className); - } - } - return 0; - } -}); - -jQuery.fn.unhighlight = function (options) { - var settings = { className: 'highlight', element: 'span' }; - jQuery.extend(settings, options); - - return this.find(settings.element + "." + settings.className).each(function () { - var parent = this.parentNode; - parent.replaceChild(this.firstChild, this); - parent.normalize(); - }).end(); -}; - -jQuery.fn.highlight = function (words, options) { - var settings = { className: 'highlight', element: 'span', caseSensitive: false, wordsOnly: false }; - jQuery.extend(settings, options); - - if (words.constructor === String) { - words = [words]; - } - words = jQuery.grep(words, function(word, i){ - return word != ''; - }); - words = jQuery.map(words, function(word, i) { - return word.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); - }); - if (words.length == 0) { return this; }; - - var flag = settings.caseSensitive ? "" : "i"; - var pattern = "(" + words.join("|") + ")"; - if (settings.wordsOnly) { - pattern = "\\b" + pattern + "\\b"; - } - var re = new RegExp(pattern, flag); - - return this.each(function () { - jQuery.highlight(this, re, settings.element, settings.className); - }); -}; - diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 31b4c941cf8d..5e51aff8217a 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -3,7 +3,6 @@ """ import json import logging -import markupsafe import requests from requests.exceptions import RequestException from uuid import uuid4 @@ -16,6 +15,7 @@ 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 @@ -28,6 +28,8 @@ from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable log = logging.getLogger(__name__) +HIGHLIGHT_TAG = "span" +HIGHLIGHT_CLASS = "note-highlight" class NoteJSONEncoder(JSONEncoder): @@ -73,7 +75,7 @@ def get_token_url(course_id): }) -def send_request(user, course_id, path="", query_string=""): +def send_request(user, course_id, path="", query_string=None): """ Sends a request with appropriate parameters and headers. """ @@ -86,6 +88,9 @@ def send_request(user, course_id, path="", query_string=""): if query_string: params.update({ "text": query_string, + "highlight": True, + "highlight_tag": HIGHLIGHT_TAG, + "highlight_class": HIGHLIGHT_CLASS, }) try: @@ -132,8 +137,8 @@ def preprocess_collection(user, course, collection): with store.bulk_operations(course.id): for model in collection: model.update({ - u"text": markupsafe.escape(model["text"]), - u"quote": markupsafe.escape(model["quote"]), + u"text": sanitize_html(model["text"]), + u"quote": sanitize_html(model["quote"]), u"updated": dateutil_parse(model["updated"]), }) usage_id = model["usage_id"] diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 4b0b39691be7..677a07d98f7e 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -445,8 +445,8 @@ def test_preprocess_collection_escaping(self): self.assertItemsEqual( [{ - u"quote": u"test <script>alert('test')</script>", - u"text": u"text "<>&'", + 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, @@ -679,6 +679,9 @@ def test_send_request_with_query_string(self, mock_get, mock_get_id_token, mock_ "user": "anonymous_id", "course_id": unicode(self.course.id), "text": "text", + "highlight": True, + "highlight_tag": "span", + "highlight_class": "note-highlight", } ) diff --git a/lms/static/js/edxnotes/views/tabs/course_structure.js b/lms/static/js/edxnotes/views/tabs/course_structure.js index 3ed0e4a758ae..9d1a5918ddbc 100644 --- a/lms/static/js/edxnotes/views/tabs/course_structure.js +++ b/lms/static/js/edxnotes/views/tabs/course_structure.js @@ -10,7 +10,9 @@ define([ title: 'Location in Course', renderContent: function () { - var courseStructure = this.collection.getCourseStructure(); + var courseStructure = this.collection.getCourseStructure(), + container = document.createDocumentFragment(); + _.each(courseStructure.chapters, function (chapterInfo) { var group = this.getGroup(chapterInfo); _.each(chapterInfo.children, function (location) { @@ -26,9 +28,9 @@ define([ }, this); } }, this); - group.render().$el.appendTo(this.$el); + container.appendChild(group.render().el); }, this); - + this.$el.append(container); return this; }, diff --git a/lms/static/js/edxnotes/views/tabs/search_results.js b/lms/static/js/edxnotes/views/tabs/search_results.js index c700d33fc40a..f9914e32fec1 100644 --- a/lms/static/js/edxnotes/views/tabs/search_results.js +++ b/lms/static/js/edxnotes/views/tabs/search_results.js @@ -2,7 +2,7 @@ 'use strict'; define([ 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view', - 'js/edxnotes/views/search_box', 'jquery.highlight' + 'js/edxnotes/views/search_box' ], function (gettext, TabPanelView, TabView, SearchBoxView) { var SearchResultsView = TabView.extend({ PanelConstructor: TabPanelView.extend({ @@ -12,19 +12,10 @@ define([ return [ TabPanelView.prototype.className, 'note-group' - ].join(' ') + ].join(' '); }, - highlightMatchedText: true, renderContent: function () { this.$el.append(this.getNotes(this.collection.toArray())); - if (this.highlightMatchedText) { - this.$('.note-comment-p').highlight(this.options.searchQuery, { - element: 'span', - className: 'note-highlight', - caseSensitive: false, - wordsOnly: false - }); - } return this; } }), @@ -36,12 +27,12 @@ define([ return [ TabPanelView.prototype.className, 'note-group' - ].join(' ') + ].join(' '); }, renderContent: function () { var message = gettext('No results found for "%(query_string)s". Please try searching again.'); - this.$el.append($('

    ', { + this.$el.append($('

    ', { text: interpolate(message, { query_string: this.options.searchQuery }, true) 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 index 87ac532a2598..29fd52855eed 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -82,10 +82,6 @@ define([ }); expect(view.$('#search-results-panel')).toExist(); expect(view.$('#search-results-panel')).toBeFocused(); - expect(view.$('.note-comments').eq(1)).toContainHtml( - 'Second' - ); - expect(view.$('.note-excerpt .note-highlight')).not.toExist(); expect(view.$('.note')).toHaveLength(3); view.searchResults.collection.each(function (model, index) { expect(model.get('text')).toBe(notes[index].text); @@ -119,7 +115,6 @@ define([ expect(view.$('#search-results-panel')).not.toExist(); expect(view.$('#no-results-panel')).toBeFocused(); expect(view.$('#no-results-panel')).toExist(); - expect(view.$('.note-highlight')).not.toExist(); expect(view.$('#no-results-panel')).toContainText( 'No results found for "some text".' ); @@ -169,7 +164,6 @@ define([ expect(view.$('.wrapper-msg')).not.toHaveClass('is-hidden'); expect(view.$('.wrapper-msg .copy')).toContainText('test error message'); - expect(view.$('.note-highlight')).not.toExist(); expect(view.$('.ui-loading')).toHaveClass('is-hidden'); submitForm(view.searchBox, 'Second'); @@ -177,7 +171,6 @@ define([ expect(view.$('.wrapper-msg')).toHaveClass('is-hidden'); expect(view.$('.wrapper-msg .copy')).toBeEmpty(); - expect(view.$('.note-highlight')).toExist(); }); it('can correctly update search results', function () { diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 958397615c0a..4392a90a2586 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -23,7 +23,6 @@ 'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents', 'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate', 'jquery.url': 'xmodule_js/common_static/js/vendor/url.min', - 'jquery.highlight': 'xmodule_js/common_static/js/vendor/jquery.highlight', 'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair', 'date': 'xmodule_js/common_static/js/vendor/date', 'underscore': 'xmodule_js/common_static/js/vendor/underscore-min', @@ -153,10 +152,6 @@ deps: ['jquery'], exports: 'jQuery.fn.url' }, - 'jquery.highlight': { - deps: ['jquery'], - exports: 'jQuery.fn.highlight' - }, 'datepair': { deps: ['jquery.ui', 'jquery.timepicker'] }, diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 7ce4b344bce4..2dadab2bb6e3 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -38,7 +38,6 @@ lib_paths: - xmodule_js/common_static/js/vendor/jquery.min.js - xmodule_js/common_static/js/vendor/jquery-ui.min.js - xmodule_js/common_static/js/vendor/jquery.cookie.js - - xmodule_js/common_static/js/vendor/jquery.highlight.js - xmodule_js/common_static/js/vendor/flot/jquery.flot.js - xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js - xmodule_js/common_static/js/vendor/URI.min.js diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index 931f24607bda..296cf30c30bd 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -43,7 +43,6 @@ "date": "js/vendor/date", "backbone": "js/vendor/backbone-min", "underscore.string": "js/vendor/underscore.string.min", - "jquery.highlight": "js/vendor/jquery.highlight", // Files needed by OVA "annotator": "js/vendor/ova/annotator-full", "annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth", @@ -75,10 +74,6 @@ "jquery": { exports: "$" }, - "jquery.highlight": { - deps: ["jquery"], - exports: "jQuery.fn.highlight" - }, "underscore": { exports: "_" }, diff --git a/lms/static/sass/course/_student-notes.scss b/lms/static/sass/course/_student-notes.scss index a36d4eb76da2..7ca4e9e0e649 100644 --- a/lms/static/sass/course/_student-notes.scss +++ b/lms/static/sass/course/_student-notes.scss @@ -181,6 +181,10 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; padding: auto; margin: auto; } + + .note-highlight { + background-color: #FFFF88; + } } } } From ed58ddc34961a3c1be5488ddbcf0dd11e054505c Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 22 Dec 2014 18:20:26 +0200 Subject: [PATCH 36/47] TNL-931: Add eventing for Student Notes. --- CHANGELOG.rst | 2 + cms/static/js/factories/base.js | 2 +- cms/static/require-config.js | 2 +- common/lib/xmodule/xmodule/js/js_test.yml | 2 +- .../crowdsource_hinter/display_spec.coffee | 2 +- common/static/coffee/spec/logger_spec.coffee | 35 ----- common/static/coffee/src/logger.coffee | 48 ------- common/static/js/spec/logger_spec.js | 108 ++++++++++++++ common/static/js/src/logger.js | 82 +++++++++++ lms/envs/common.py | 1 + lms/static/coffee/src/courseware.coffee | 1 - lms/static/coffee/src/main.coffee | 2 +- lms/static/js/edxnotes/plugins/events.js | 133 ++++++++++++++++++ lms/static/js/edxnotes/utils/logger.js | 45 ++++-- lms/static/js/edxnotes/views/note_item.js | 30 +++- lms/static/js/edxnotes/views/notes_factory.js | 12 +- lms/static/js/edxnotes/views/search_box.js | 21 +-- lms/static/js/spec/edxnotes/helpers.js | 14 +- .../js/spec/edxnotes/models/note_spec.js | 2 +- .../js/spec/edxnotes/plugins/events_spec.js | 128 +++++++++++++++++ .../js/spec/edxnotes/utils/logger_spec.js | 19 ++- .../js/spec/edxnotes/views/note_item_spec.js | 50 ++++++- .../js/spec/edxnotes/views/search_box_spec.js | 15 ++ .../views/tabs/search_results_spec.js | 6 +- lms/static/js/spec/main.js | 5 + lms/static/js_test.yml | 2 +- lms/static/js_test_coffee.yml | 2 +- lms/static/require-config-lms.js | 9 ++ 28 files changed, 639 insertions(+), 141 deletions(-) delete mode 100644 common/static/coffee/spec/logger_spec.coffee delete mode 100644 common/static/coffee/src/logger.coffee create mode 100644 common/static/js/spec/logger_spec.js create mode 100644 common/static/js/src/logger.js create mode 100644 lms/static/js/edxnotes/plugins/events.js create mode 100644 lms/static/js/spec/edxnotes/plugins/events_spec.js diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 09f6187f79b2..b12fc4721622 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ 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 diff --git a/cms/static/js/factories/base.js b/cms/static/js/factories/base.js index 49671bb8ec97..b33a8123faf0 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 0c5646d037e8..7a3a2d58a5dc 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/lib/xmodule/xmodule/js/js_test.yml b/common/lib/xmodule/xmodule/js/js_test.yml index 95d4df3e3f45..6af10abaa553 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 b2a1409d4f12..80910ddc9503 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/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee deleted file mode 100644 index 69631170c9d6..000000000000 --- 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 5a13ca8264d9..000000000000 --- 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/js/spec/logger_spec.js b/common/static/js/spec/logger_spec.js new file mode 100644 index 000000000000..37e41c7a7428 --- /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 000000000000..2495358e4508 --- /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/lms/envs/common.py b/lms/envs/common.py index bd4297293713..0b0e3e28c04d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1240,6 +1240,7 @@ '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', }, diff --git a/lms/static/coffee/src/courseware.coffee b/lms/static/coffee/src/courseware.coffee index 06a444c8cd21..50fcb3ecec43 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 770b17ea66d8..561225761db7 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/plugins/events.js b/lms/static/js/edxnotes/plugins/events.js new file mode 100644 index 000000000000..969c7a1c8b82 --- /dev/null +++ b/lms/static/js/edxnotes/plugins/events.js @@ -0,0 +1,133 @@ +;(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) { + var data = { + 'notes': _.map(annotations, function (annotation) { + return {'note_id': annotation.id}; + }) + }; + 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 = _.extend( + this.getDefaultData(annotation), + this.getText('old_note_text', this.oldNoteText) + ); + this.log('edx.course.student_notes.edited', data); + }, + + annotationDeleted: function (annotation) { + var 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) && text.length > limit) { + text = String(text).slice(0, limit); + truncated = true; + } + + info[fieldName] = text; + info[fieldName + '_truncated'] = truncated; + + return info; + }, + + log: function (eventName, data) { + this.annotator.logger.emit(eventName, data); + } + }); +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/utils/logger.js b/lms/static/js/edxnotes/utils/logger.js index 34ec5dadfbb1..92ac9a4175f2 100644 --- a/lms/static/js/edxnotes/utils/logger.js +++ b/lms/static/js/edxnotes/utils/logger.js @@ -1,8 +1,8 @@ ;(function (define) { 'use strict'; -define([], function () { +define(['underscore', 'logger'], function (_, Logger) { var loggers = [], - Logger, now, destroyLogger; + NotesLogger, now, destroyLogger; now = function () { if (performance && performance.now) { @@ -33,12 +33,12 @@ define([], function () { }; /** - * Logger constructor. + * NotesLogger constructor. * @constructor * @param {String} id Id of the logger. * @param {Boolean|Number} mode Outputs messages to the Web Console if true. */ - Logger = function (id, mode) { + NotesLogger = function (id, mode) { this.id = id; this.historyStorage = []; this.timeStorage = {}; @@ -53,7 +53,7 @@ define([], function () { * @param {String} logType The type of the log message. * @param {Arguments} args Information that will be stored. */ - Logger.prototype._log = function (logType, args) { + NotesLogger.prototype._log = function (logType, args) { if (!this.logLevel) { return false; } @@ -72,21 +72,21 @@ define([], function () { /** * Outputs a message to the Web Console and store it in the history. */ - Logger.prototype.log = function () { + NotesLogger.prototype.log = function () { this._log('log', arguments); }; /** * Outputs an error message to the Web Console and store it in the history. */ - Logger.prototype.error = function () { + NotesLogger.prototype.error = function () { this._log('error', arguments); }; /** * Adds information to the history. */ - Logger.prototype.updateHistory = function () { + NotesLogger.prototype.updateHistory = function () { this.historyStorage.push(arguments); }; @@ -94,7 +94,7 @@ define([], function () { * Returns the history for the logger. * @return {Array} */ - Logger.prototype.getHistory = function () { + NotesLogger.prototype.getHistory = function () { return this.historyStorage; }; @@ -102,15 +102,15 @@ define([], function () { * Starts a timer you can use to track how long an operation takes. * @param {String} label Timer name. */ - Logger.prototype.time = function (label) { + NotesLogger.prototype.time = function (label) { this.timeStorage[label] = now(); }; /** - * Stops a timer that was previously started by calling Logger.prototype.time(). + * Stops a timer that was previously started by calling NotesLogger.prototype.time(). * @param {String} label Timer name. */ - Logger.prototype.timeEnd = function (label) { + NotesLogger.prototype.timeEnd = function (label) { if (!this.timeStorage[label]) { return null; } @@ -119,13 +119,28 @@ define([], function () { delete this.timeStorage[label]; }; - Logger.prototype.destroy = function () { + 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 Logger(id, mode); + var logger = new NotesLogger(id, mode); loggers.push(logger); return logger; }, diff --git a/lms/static/js/edxnotes/views/note_item.js b/lms/static/js/edxnotes/views/note_item.js index 6a9b3002f3c4..9488496c630f 100644 --- a/lms/static/js/edxnotes/views/note_item.js +++ b/lms/static/js/edxnotes/views/note_item.js @@ -1,8 +1,9 @@ ;(function (define, undefined) { 'use strict'; define([ - 'jquery', 'backbone', 'js/edxnotes/utils/template' -], function ($, Backbone, templateUtils) { + 'jquery', 'underscore','backbone', 'js/edxnotes/utils/template', + 'js/edxnotes/utils/logger' +], function ($, _, Backbone, templateUtils, NotesLogger) { var NoteItemView = Backbone.View.extend({ tagName: 'article', className: 'note', @@ -10,11 +11,13 @@ define([ return 'note-' + _.uniqueId(); }, events: { - 'click .note-excerpt-more-link': 'moreHandler' + '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); }, @@ -39,6 +42,27 @@ define([ 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; } }); diff --git a/lms/static/js/edxnotes/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js index f1030cc5cb51..2bf6e159c8fb 100644 --- a/lms/static/js/edxnotes/views/notes_factory.js +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -2,9 +2,10 @@ 'use strict'; define([ 'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger', - 'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller' -], function ($, _, Annotator, Logger) { - var plugins = ['Auth', 'Store', 'Scroller'], + '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; /** @@ -31,6 +32,9 @@ define([ token: params.token, tokenUrl: params.tokenUrl }, + events: { + stringLimit: 300 + }, store: { prefix: prefix, annotationData: defaultParams, @@ -73,7 +77,7 @@ define([ getAnnotator = function (element, params) { var el = $(element), options = getOptions(el, params), - logger = Logger.getLogger(element.id, params.debug), + logger = NotesLogger.getLogger(element.id, params.debug), annotator; annotator = el.annotator(options).data('annotator'); diff --git a/lms/static/js/edxnotes/views/search_box.js b/lms/static/js/edxnotes/views/search_box.js index 43244631201c..4cebed577d58 100644 --- a/lms/static/js/edxnotes/views/search_box.js +++ b/lms/static/js/edxnotes/views/search_box.js @@ -3,7 +3,7 @@ define([ 'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/utils/logger', 'js/edxnotes/collections/notes' -], function ($, _, Backbone, gettext, Logger, NotesCollection) { +], function ($, _, Backbone, gettext, NotesLogger, NotesCollection) { var SearchBoxView = Backbone.View.extend({ events: { 'submit': 'submitHandler' @@ -20,7 +20,7 @@ define([ error: function () {}, complete: function () {} }); - this.logger = Logger.getLogger('search_box', this.options.debug); + this.logger = NotesLogger.getLogger('search_box', this.options.debug); this.$el.removeClass('is-hidden'); this.isDisabled = false; this.logger.log('initialized'); @@ -92,7 +92,10 @@ define([ var args = this.prepareData(data); if (args) { this.options.search.apply(this, args); - this.logger.log('Successful response', 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); } @@ -135,17 +138,15 @@ define([ * @return {jQuery.Deferred} */ sendRequest: function (text) { - this.logger.log('sendRequest', { - action: this.el.action, - method: this.el.method, - text: text - }); - return $.ajax({ + var settings = { url: this.el.action, type: this.el.method, dataType: 'json', data: {text: text} - }); + }; + + this.logger.log(settings); + return $.ajax(settings); } }); diff --git a/lms/static/js/spec/edxnotes/helpers.js b/lms/static/js/spec/edxnotes/helpers.js index 844d2e21cbe6..09cf2634b9e0 100644 --- a/lms/static/js/spec/edxnotes/helpers.js +++ b/lms/static/js/spec/edxnotes/helpers.js @@ -1,7 +1,7 @@ define(['underscore'], function(_) { 'use strict'; var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", - LONG_TEXT, TRUNCATED_TEXT, SHORT_TEXT, + LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT, base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes; LONG_TEXT = [ @@ -14,7 +14,7 @@ define(['underscore'], function(_) { 'sint occaecat cupidatat non proident, sunt in culpa ', 'qui officia deserunt mollit anim id est laborum.' ].join(''); - TRUNCATED_TEXT = [ + 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 ', @@ -22,9 +22,16 @@ define(['underscore'], function(_) { '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) { @@ -149,6 +156,7 @@ define(['underscore'], function(_) { return { LONG_TEXT: LONG_TEXT, + PRUNED_TEXT: PRUNED_TEXT, TRUNCATED_TEXT: TRUNCATED_TEXT, SHORT_TEXT: SHORT_TEXT, base64Encode: base64Encode, diff --git a/lms/static/js/spec/edxnotes/models/note_spec.js b/lms/static/js/spec/edxnotes/models/note_spec.js index c57c0f1b7b8b..285bb090d9da 100644 --- a/lms/static/js/spec/edxnotes/models/note_spec.js +++ b/lms/static/js/spec/edxnotes/models/note_spec.js @@ -21,7 +21,7 @@ define([ var model = this.collection.at(0); // is_expanded = false, show_link = true - expect(model.getNoteText()).toBe(Helpers.TRUNCATED_TEXT); + 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); 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 000000000000..372fbb793197 --- /dev/null +++ b/lms/static/js/spec/edxnotes/plugins/events_spec.js @@ -0,0 +1,128 @@ +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' + }; + + 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', id: 'note-456'}]] + ); + expect(Logger.log).toHaveBeenCalledWith( + 'edx.course.student_notes.viewed', { + 'notes': [{'note_id': 'note-123'}, {'note_id': 'note-456'}] + } + ); + }); + + 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 old_note = note, + new_note = $.extend({}, note, {text: 'text-456'}); + + this.annotator.publish('annotationEditorShown', [this.annotator.editor, old_note]); + expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123'); + this.annotator.publish('annotationUpdated', new_note); + this.annotator.publish('annotationEditorHidden', [this.annotator.editor, new_note]); + + 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 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 truncate values of some fields', function() { + var old_note = $.extend({}, note, {text: Helpers.LONG_TEXT}), + new_note = $.extend({}, note, { + text: Helpers.LONG_TEXT + '123', + quote: Helpers.LONG_TEXT + '123' + }); + + this.annotator.publish('annotationEditorShown', [this.annotator.editor, old_note]); + expect(this.annotator.plugins.Events.oldNoteText).toBe(Helpers.LONG_TEXT); + this.annotator.publish('annotationUpdated', new_note); + this.annotator.publish('annotationEditorHidden', [this.annotator.editor, new_note]); + + 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/utils/logger_spec.js b/lms/static/js/spec/edxnotes/utils/logger_spec.js index 327613f842cb..95430e41bbc8 100644 --- a/lms/static/js/spec/edxnotes/utils/logger_spec.js +++ b/lms/static/js/spec/edxnotes/utils/logger_spec.js @@ -1,15 +1,16 @@ define([ - 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers' -], function(Logger, customMatchers) { + 'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers' +], function(Logger, NotesLogger, customMatchers) { 'use strict'; - describe('Edxnotes logger', function() { + describe('Edxnotes NotesLogger', function() { var getLogger = function(id, mode) { - return Logger.getLogger(id, mode); + return NotesLogger.getLogger(id, mode); }; beforeEach(function () { spyOn(window.console, 'log'); spyOn(window.console, 'error'); + spyOn(Logger, 'log'); customMatchers(this); }); @@ -108,8 +109,16 @@ define([ expect(log[0]).toBe('log'); expect(log[1][0]).toBe('id'); expect(log[1][1]).toBe('timer'); - expect(log[1][2]).toBeInRange(190, 210); + 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 index dd818fac2e68..ca1af97646d5 100644 --- a/lms/static/js/spec/edxnotes/views/note_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -1,16 +1,26 @@ define([ - 'jquery', 'underscore', 'js/common_helpers/template_helpers', - 'js/spec/edxnotes/helpers', 'js/edxnotes/models/note', - 'js/edxnotes/views/note_item', 'js/spec/edxnotes/custom_matchers' -], function($, _, TemplateHelpers, Helpers, NoteModel, NoteItemView, customMatchers) { + '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 + quote: Helpers.LONG_TEXT, + unit: { + url: 'http://example.com/' + } })); return new NoteItemView({model: model}).render(); @@ -19,12 +29,15 @@ define([ beforeEach(function() { customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/note-item'); + spyOn(Logger, 'log').andCallThrough(); }); it('can be rendered properly', function() { - var view = getView(); + var view = getView(), + unitLink = view.$('.reference-unit-link').get(0); + expect(view.$el).toContain('.note-excerpt-more-link'); - expect(view.$el).toContainText(Helpers.TRUNCATED_TEXT); + expect(view.$el).toContainText(Helpers.PRUNED_TEXT); expect(view.$el).toContainText('More'); view.$('.note-excerpt-more-link').click(); @@ -34,6 +47,8 @@ define([ 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() { @@ -41,5 +56,26 @@ define([ 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/search_box_spec.js b/lms/static/js/spec/edxnotes/views/search_box_spec.js index 11d1f2ae26e4..273a86547834 100644 --- a/lms/static/js/spec/edxnotes/views/search_box_spec.js +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -38,6 +38,7 @@ define([ beforeEach(function () { customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); + spyOn(Logger, 'log'); this.searchBox = getSearchBox(); }); @@ -72,6 +73,20 @@ define([ ); }); + 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'); 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 index 29fd52855eed..47ee9a629406 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -1,9 +1,10 @@ define([ 'jquery', 'js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers', - 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results', + 'logger', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' ], function( - $, TemplateHelpers, AjaxHelpers, TabsCollection, SearchResultsView, customMatchers + $, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView, + customMatchers ) { 'use strict'; describe('EdxNotes SearchResultsView', function() { @@ -124,6 +125,7 @@ define([ var view = getView(this.tabsCollection), requests = AjaxHelpers.requests(this); + spyOn(Logger, 'log'); submitForm(view.searchBox, 'test_query'); AjaxHelpers.respondWithJson(requests, responseJson); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 4392a90a2586..d199697b44cf 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -53,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', @@ -213,6 +214,9 @@ 'xmodule': { exports: 'XModule' }, + 'logger': { + exports: 'Logger' + }, 'sinon': { exports: 'sinon' }, @@ -538,6 +542,7 @@ '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' ]); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 2dadab2bb6e3..5dfa944db2c0 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 diff --git a/lms/static/js_test_coffee.yml b/lms/static/js_test_coffee.yml index 4e257f9e6624..60687bd41a62 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 296cf30c30bd..f515351e19eb 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -19,6 +19,11 @@ } 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 { @@ -66,6 +71,7 @@ }, shim: { "annotator_1.2.9": { + deps: ["jquery"], exports: "Annotator" }, "date": { @@ -81,6 +87,9 @@ deps: ["underscore", "jquery"], exports: "Backbone" }, + "logger": { + exports: "Logger" + }, // Needed by OVA "video.dev": { exports:"videojs" From 1c8868e62c25fa62e55ae3ea4ea71e6c4234d6d2 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 8 Jan 2015 20:40:31 -0500 Subject: [PATCH 37/47] LMS: styling note editing/creation + UI loose ends * syncing student notes color schemes * overriding vendor UI/styling to match LMS UI * syncing up notes view tab spacing/alignment * syncing up icons used in notes + LMS * removing truncation from student-made note/comment on highlight * addressing note-highlight styling --- .../discussion/discussion_spec_helper.coffee | 2 +- lms/static/sass/base/_variables.scss | 9 +- lms/static/sass/course/_student-notes.scss | 48 ++-- .../sass/course/modules/_student-notes.scss | 225 +++++++++++++++++- .../discussion/_underscore_templates.html | 2 +- 5 files changed, 257 insertions(+), 29 deletions(-) diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee index c39abffb2629..1a4bd9145291 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/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 04fa0e72033e..2d73b00afc8b 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -330,6 +330,7 @@ $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); @@ -446,8 +447,12 @@ $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, 50%); -$student-notes-highlight-color: tint($student-notes-highlight-color-base, 60%); +$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/_student-notes.scss b/lms/static/sass/course/_student-notes.scss index 7ca4e9e0e649..3c3e3367eeda 100644 --- a/lms/static/sass/course/_student-notes.scss +++ b/lms/static/sass/course/_student-notes.scss @@ -23,6 +23,14 @@ $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: @@ -76,10 +84,11 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; } .search-notes-input { + @extend %t-demi-strong; width: 55%; @include margin-right($baseline/4); padding: ($baseline/2) ($baseline*0.75); - color: $active-color; + color: $gray-d3; } .search-notes-submit { @@ -172,18 +181,15 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; background: transparent; } - .note-comment-p { - @extend %text-truncated; - } - .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: #FFFF88; + background-color: $result-highlight-color-base; } } } @@ -270,9 +276,13 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; position: relative; top: ($baseline/5); + .tabs-label, .tabs { + display: inline-block; + vertical-align: middle; + } + .tabs-label { @extend %hd-lv5; - display: inline; margin-bottom: 0; padding: ($baseline*0.75) 0; @include padding-right($baseline); @@ -283,7 +293,8 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; .tabs { @include clearfix(); @extend %ui-no-list; - display: inline; + position: relative; + bottom: -($baseline/4); } .tab { @@ -291,13 +302,8 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; display: inline; .tab-label { - @include transition(none); - @extend %shame-link-base; - display: inline-block; - vertical-align: middle; - padding: ($baseline/2) 0; - @include padding-right($baseline); - @include padding-left($baseline*0.75); + @extend %notes-tab-control; + padding: ($baseline/2) ($baseline*0.75); text-align: center; .icon { @@ -309,19 +315,21 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4; &.is-active { .tab-label { - border-bottom: ($baseline/5) solid $gray-d3; + 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 %shame-link-base; + @extend %notes-tab-control; position: relative; @include left(-($baseline*0.75)); - display: inline-block; - vertical-align: middle; - border-bottom: ($baseline/5) solid $gray-d3; padding: ($baseline/2); } } diff --git a/lms/static/sass/course/modules/_student-notes.scss b/lms/static/sass/course/modules/_student-notes.scss index 666967c6a2bd..2c0537b7f272 100644 --- a/lms/static/sass/course/modules/_student-notes.scss +++ b/lms/static/sass/course/modules/_student-notes.scss @@ -4,17 +4,50 @@ // 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. -// +toggling notes +// +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 { @@ -50,8 +83,8 @@ .annotator-notice { @extend %t-weight4; @extend %t-copy-sub1; - background: $black-t3; padding: ($baseline/4) $baseline; + background: $black-t3; } // CASE: annotator error in toggling notes @@ -59,8 +92,8 @@ .annotator-notice { @extend %t-weight4; @extend %t-copy-sub1; - background: $gray-d4; padding: ($baseline/2) $baseline; + background: $gray-d4; } // vendor customization @@ -68,8 +101,190 @@ border-color: $error-color; } -// +creating/editing notes +// +individual note (in context) // -------------------- +.annotator-outer.annotator-outer { + @extend %ui-depth4; + @extend %notes-reset-font; +} -// +listing notes +// bubble +.annotator-widget.annotator-widget { + @extend %notes-bubble; +} + +.annotator-item { + padding: ($baseline/2) !important; +} + +// +creating/editing notes (overrides for vendor styling) // -------------------- +// adding +.annotator-adder { + @extend %notes-reset-background; + + button { + @extend %notes-bubble; + position: relative; + display: block; + + &:after { + @extend %notes-reset-icon; + @extend %shame-link-base; + @include font-size(30); + position: absolute; + top: 35%; + @include left(15%); + content: "\f14b"; + } + + // using annotatorJS triangle styling for adder + &:before { + position: absolute; + @include left(8px); + bottom: -($baseline/2); + display: block; + width: 18px; + height: ($baseline/2); + content: ""; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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); + background-position: 0 0; + } + } +} + +// editing +.annotator-editor { + + .annotator-controls { + @include text-align(left); + @include clearfix(); + background: $notes-annotator-background-med !important; //matches annotator JS editing bubble triangle color + font-family: $f-sans-serif !important; + padding: 8px; + border: none !important; + border-radius: 0 !important; + + // actions + .annotator-save, .annotator-cancel { + @extend %notes-reset-background; + font-family: $f-sans-serif !important; + font-size: 14px !important; + padding: ($baseline/4) ($baseline/2) !important; + border: none; + box-shadow: none; + text-shadow: none !important; + + // removing vendor icons + &:after { + display: none !important; + } + } + + .annotator-save { + @include float(left); + } + + .annotator-cancel { + background-color: $transparent !important; + } + } + + .annotator-item { + + textarea { + @extend %notes-reset-font; + @extend %t-demi-strong; + padding: ($baseline/5) !important; + font-size: 14px !important; + line-height: 22px !important; + color: $gray-d3 !important; + background: $notes-annotator-background-light !important; //matches annotator JS editing bubble triangle color + + // STATE: hover/focus + &:hover, &:focus { + background: $notes-annotator-background-light; + } + } + } +} + + +// +listing notes (overrides for vendor styling) +// -------------------- +// highlight +.annotator-hl { + background: $student-notes-highlight-color-focus; +} + +// content +.annotator-viewer { + + // poorly scoped selector for content of a note's comment + div:first-of-type { + @extend %notes-reset-font; + padding: ($baseline/4) !important; + font-size: 14px !important; + line-height: 22px !important; + color: $gray-d2 !important; + } + + // controls + .annotator-controls { + // RTL support + @include right(0); + top: 0; + @include float(right); + @include padding-left($baseline/4); + + .annotator-delete, .annotator-edit { + position: relative; + display: inline-block; + vertical-align: middle; + + &:before { + @extend %notes-reset-icon; + @extend %shame-link-base; + @extend %t-icon4; + position: absolute; + } + } + + .annotator-edit { + @include margin-right($baseline/2); + + &:before { + top: 0; + @include left(-($baseline/4)); + content: "\f044"; + } + } + + .annotator-delete { + + &:before { + top: 0; + @include left(-($baseline/4)); + 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/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 073bdbd635e2..b26cc8dd5d58 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -571,7 +571,7 @@

    ${course.display_name_with_default}

    -${secondaryAction("edit", "pencil", _("Edit"))} +${secondaryAction("edit", "pencil-square-o", _("Edit"))} ${secondaryAction("delete", "remove", _("Delete"))}