Commit 770a45b7 by Muzaffar yousaf

Merge pull request #11363 from edx/notes-pagination

Notes pagination
parents 656913c7 e1a13a14
......@@ -7,11 +7,12 @@ import re
from uuid import uuid4
from datetime import datetime
from copy import deepcopy
from math import ceil
from urllib import urlencode
from .http import StubHttpRequestHandler, StubHttpService
# pylint: disable=invalid-name
class StubEdxNotesServiceHandler(StubHttpRequestHandler):
"""
Handler for EdxNotes requests.
......@@ -165,7 +166,7 @@ class StubEdxNotesServiceHandler(StubHttpRequestHandler):
"""
Return the note by note id.
"""
notes = self.server.get_notes()
notes = self.server.get_all_notes()
result = self.server.filter_by_id(notes, note_id)
if result:
self.respond(content=result[0])
......@@ -191,6 +192,53 @@ class StubEdxNotesServiceHandler(StubHttpRequestHandler):
else:
self.respond(404, "404 Not Found")
@staticmethod
def _get_next_prev_url(url_path, query_params, page_num, page_size):
"""
makes url with the query params including pagination params
for pagination next and previous urls
"""
query_params = deepcopy(query_params)
query_params.update({
"page": page_num,
"page_size": page_size
})
return url_path + "?" + urlencode(query_params)
def _get_paginated_response(self, notes, page_num, page_size):
"""
Returns a paginated response of notes.
"""
start = (page_num - 1) * page_size
end = start + page_size
total_notes = len(notes)
url_path = "http://{server_address}:{port}{path}".format(
server_address=self.client_address[0],
port=self.server.port,
path=self.path_only
)
next_url = None if end >= total_notes else self._get_next_prev_url(
url_path, self.get_params, page_num + 1, page_size
)
prev_url = None if page_num == 1 else self._get_next_prev_url(
url_path, self.get_params, page_num - 1, page_size)
# Get notes from range
notes = deepcopy(notes[start:end])
paginated_response = {
'total': total_notes,
'num_pages': int(ceil(float(total_notes) / page_size)),
'current_page': page_num,
'rows': notes,
'next': next_url,
'start': start,
'previous': prev_url
}
return paginated_response
def _search(self):
"""
Search for a notes by user id, course_id and usage_id.
......@@ -199,32 +247,35 @@ class StubEdxNotesServiceHandler(StubHttpRequestHandler):
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)
page = int(self.get_params.get("page", 1))
page_size = int(self.get_params.get("page_size", 2))
if user is None:
self.respond(400, "Bad Request")
return
notes = self.server.get_notes()
notes = self.server.get_all_notes()
if course_id is not None:
notes = self.server.filter_by_course_id(notes, course_id)
if usage_id is not None:
notes = self.server.filter_by_usage_id(notes, usage_id)
if text:
notes = self.server.search(notes, text)
self.respond(content={
"total": len(notes),
"rows": notes,
})
self.respond(content=self._get_paginated_response(notes, page, page_size))
def _collection(self):
"""
Return all notes for the user.
"""
user = self.get_params.get("user", None)
page = int(self.get_params.get("page", 1))
page_size = int(self.get_params.get("page_size", 2))
notes = self.server.get_all_notes()
if user is None:
self.send_response(400, content="Bad Request")
return
notes = self.server.get_notes()
notes = self._get_paginated_response(notes, page, page_size)
self.respond(content=notes)
def _cleanup(self):
......@@ -245,9 +296,9 @@ class StubEdxNotesService(StubHttpService):
super(StubEdxNotesService, self).__init__(*args, **kwargs)
self.notes = list()
def get_notes(self):
def get_all_notes(self):
"""
Returns a list of all notes.
Returns a list of all notes without pagination
"""
notes = deepcopy(self.notes)
notes.reverse()
......
......@@ -3,6 +3,7 @@ Stub implementation of an HTTP service.
"""
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
import urllib
import urlparse
import threading
import json
......@@ -217,6 +218,8 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
`format_str` is a string with old-style Python format escaping;
`args` is an array of values to fill into the string.
"""
if not args:
format_str = urllib.unquote(format_str)
return u"{0} - - [{1}] {2}\n".format(
self.client_address[0],
self.log_date_time_string(),
......
"""
Unit tests for stub EdxNotes implementation.
"""
import urlparse
import json
import unittest
import requests
......@@ -19,7 +19,7 @@ class StubEdxNotesServiceTest(unittest.TestCase):
"""
super(StubEdxNotesServiceTest, self).setUp()
self.server = StubEdxNotesService()
dummy_notes = self._get_dummy_notes(count=2)
dummy_notes = self._get_dummy_notes(count=5)
self.server.add_notes(dummy_notes)
self.addCleanup(self.server.shutdown)
......@@ -99,17 +99,48 @@ class StubEdxNotesServiceTest(unittest.TestCase):
self.assertEqual(response.status_code, 404)
def test_search(self):
# Without user
response = requests.get(self._get_url("api/v1/search"))
self.assertEqual(response.status_code, 400)
# get response with default page and page size
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())
self._verify_pagination_info(
response=response.json(),
total_notes=5,
num_pages=3,
notes_per_page=2,
start=0,
current_page=1,
next_page=2,
previous_page=None
)
response = requests.get(self._get_url("api/v1/search"))
self.assertEqual(response.status_code, 400)
# search notes with text that don't exist
response = requests.get(self._get_url("api/v1/search"), params={
"user": "dummy-user-id",
"usage_id": "dummy-usage-id",
"course_id": "dummy-course-id",
"text": "world war 2"
})
self.assertTrue(response.ok)
self._verify_pagination_info(
response=response.json(),
total_notes=0,
num_pages=0,
notes_per_page=0,
start=0,
current_page=1,
next_page=None,
previous_page=None
)
def test_delete(self):
notes = self._get_notes()
......@@ -119,7 +150,7 @@ class StubEdxNotesServiceTest(unittest.TestCase):
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()
remaining_notes = self.server.get_all_notes()
self.assertNotIn(note["id"], [note["id"] for note in remaining_notes])
self.assertEqual(len(remaining_notes), 0)
......@@ -139,24 +170,149 @@ class StubEdxNotesServiceTest(unittest.TestCase):
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
self.assertEqual(response.status_code, 404)
# pylint: disable=too-many-arguments
def _verify_pagination_info(
self,
response,
total_notes,
num_pages,
notes_per_page,
current_page,
previous_page,
next_page,
start
):
"""
Verify the pagination information.
Argument:
response: response from api
total_notes: total notes in the response
num_pages: total number of pages in response
notes_per_page: number of notes in the response
current_page: current page number
previous_page: previous page number
next_page: next page number
start: start of the current page
"""
def get_page_value(url):
"""
Return page value extracted from url.
"""
if url is None:
return None
parsed = urlparse.urlparse(url)
query_params = urlparse.parse_qs(parsed.query)
page = query_params["page"][0]
return page if page is None else int(page)
self.assertEqual(response["total"], total_notes)
self.assertEqual(response["num_pages"], num_pages)
self.assertEqual(len(response["rows"]), notes_per_page)
self.assertEqual(response["current_page"], current_page)
self.assertEqual(get_page_value(response["previous"]), previous_page)
self.assertEqual(get_page_value(response["next"]), next_page)
self.assertEqual(response["start"], start)
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)
"""
Test paginated response of notes api
"""
# Without user
response = requests.get(self._get_url("api/v1/annotations"))
self.assertEqual(response.status_code, 400)
# Without any pagination parameters
response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"})
self.assertTrue(response.ok)
self._verify_pagination_info(
response=response.json(),
total_notes=5,
num_pages=3,
notes_per_page=2,
start=0,
current_page=1,
next_page=2,
previous_page=None
)
# With pagination parameters
response = requests.get(self._get_url("api/v1/annotations"), params={
"user": "dummy-user-id",
"page": 2,
"page_size": 3
})
self.assertTrue(response.ok)
self._verify_pagination_info(
response=response.json(),
total_notes=5,
num_pages=2,
notes_per_page=2,
start=3,
current_page=2,
next_page=None,
previous_page=1
)
def test_notes_collection_next_previous_with_one_page(self):
"""
Test next and previous urls of paginated response of notes api
when number of pages are 1
"""
response = requests.get(self._get_url("api/v1/annotations"), params={
"user": "dummy-user-id",
"page_size": 10
})
self.assertTrue(response.ok)
self._verify_pagination_info(
response=response.json(),
total_notes=5,
num_pages=1,
notes_per_page=5,
start=0,
current_page=1,
next_page=None,
previous_page=None
)
def test_notes_collection_when_no_notes(self):
"""
Test paginated response of notes api when there's no note present
"""
# Delete all notes
self.test_cleanup()
# Get default page
response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"})
self.assertTrue(response.ok)
self._verify_pagination_info(
response=response.json(),
total_notes=0,
num_pages=0,
notes_per_page=0,
start=0,
current_page=1,
next_page=None,
previous_page=None
)
def test_cleanup(self):
response = requests.put(self._get_url("cleanup"))
self.assertTrue(response.ok)
self.assertEqual(len(self.server.get_notes()), 0)
self.assertEqual(len(self.server.get_all_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)
self.assertEqual(len(self._get_notes()), 7)
response = requests.post(self._get_url("create_notes"))
self.assertEqual(response.status_code, 400)
......@@ -177,7 +333,7 @@ class StubEdxNotesServiceTest(unittest.TestCase):
"""
Return a list of notes from the stub EdxNotes service.
"""
notes = self.server.get_notes()
notes = self.server.get_all_notes()
self.assertGreater(len(notes), 0, "Notes are empty.")
return notes
......
......@@ -63,3 +63,8 @@ class PaginatedUIMixin(object):
def is_enabled(self, css):
"""Return whether the given element is not disabled."""
return 'is-disabled' not in self.q(css=css).attrs('class')[0]
@property
def footer_visible(self):
""" Return True if footer is visible else False"""
return self.q(css='.pagination.bottom').visible
from bok_choy.page_object import PageObject, PageLoadError, unguarded
from bok_choy.promise import BrokenPromise, EmptyPromise
from .course_page import CoursePage
from ..common.paging import PaginatedUIMixin
from ...tests.helpers import disable_animations
from selenium.webdriver.common.action_chains import ActionChains
......@@ -114,7 +115,7 @@ class EdxNotesPageItem(NoteChild):
"""
BODY_SELECTOR = ".note"
UNIT_LINK_SELECTOR = "a.reference-unit-link"
TAG_SELECTOR = "a.reference-tags"
TAG_SELECTOR = "span.reference-tags"
def go_to_unit(self, unit_page=None):
self.q(css=self._bounded_selector(self.UNIT_LINK_SELECTOR)).click()
......@@ -242,7 +243,7 @@ class SearchResultsView(EdxNotesPageView):
TAB_SELECTOR = ".tab#view-search-results"
class EdxNotesPage(CoursePage):
class EdxNotesPage(CoursePage, PaginatedUIMixin):
"""
EdxNotes page.
"""
......@@ -348,6 +349,10 @@ class EdxNotesPage(CoursePage):
children = self.q(css='.note-group')
return [EdxNotesTagsGroup(self.browser, child.get_attribute("id")) for child in children]
def count(self):
""" Returns the total number of notes in the list """
return len(self.q(css='div.wrapper-note-excerpts').results)
class EdxNotesPageNoContent(CoursePage):
"""
......
"""
Registers the "edX Notes" feature for the edX platform.
"""
from django.conf import settings
from django.utils.translation import ugettext_noop
from courseware.tabs import EnrolledTab
......@@ -27,4 +26,20 @@ class EdxNotesTab(EnrolledTab):
"""
if not super(EdxNotesTab, cls).is_enabled(course, user=user):
return False
if not settings.FEATURES.get("ENABLE_EDXNOTES") or is_harvard_notes_enabled(course):
return False
return course.edxnotes
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))
......@@ -7,7 +7,7 @@ from django.conf.urls import patterns, url
urlpatterns = patterns(
"edxnotes.views",
url(r"^/$", "edxnotes", name="edxnotes"),
url(r"^/search/$", "search_notes", name="search_notes"),
url(r"^/notes/$", "notes", name="notes"),
url(r"^/token/$", "get_token", name="get_token"),
url(r"^/visibility/$", "edxnotes_visibility", name="edxnotes_visibility"),
)
......@@ -7,6 +7,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.conf import settings
from django.core.urlresolvers import reverse
from django.views.decorators.http import require_GET
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access
......@@ -18,8 +19,10 @@ from edxnotes.helpers import (
get_edxnotes_id_token,
get_notes,
is_feature_enabled,
search,
get_course_position,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
NoteJSONEncoder,
)
......@@ -30,6 +33,13 @@ log = logging.getLogger(__name__)
def edxnotes(request, course_id):
"""
Displays the EdxNotes page.
Arguments:
request: HTTP request object
course_id: course id
Returns:
Rendered HTTP response.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
......@@ -37,20 +47,20 @@ def edxnotes(request, course_id):
if not is_feature_enabled(course):
raise Http404
try:
notes = get_notes(request.user, course)
except EdxNotesServiceUnavailable:
raise Http404
notes_info = get_notes(request, course)
has_notes = (len(notes_info.get('results')) > 0)
context = {
"course": course,
"search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}),
"notes": notes,
"debug": json.dumps(settings.DEBUG),
"notes_endpoint": reverse("notes", kwargs={"course_id": course_id}),
"notes": notes_info,
"page_size": DEFAULT_PAGE_SIZE,
"debug": settings.DEBUG,
'position': None,
'disabled_tabs': settings.NOTES_DISABLED_TABS,
'has_notes': has_notes,
}
if not notes:
if not has_notes:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id, request.user, course, depth=2
)
......@@ -66,27 +76,97 @@ def edxnotes(request, course_id):
return render_to_response("edxnotes/edxnotes.html", context)
@require_GET
@login_required
def search_notes(request, course_id):
def notes(request, course_id):
"""
Handles search requests.
Notes view to handle list and search requests.
Query parameters:
page: page number to get
page_size: number of items in the page
text: text string to search. If `text` param is missing then get all the
notes for the current user for this course else get only those notes
which contain the `text` value.
Arguments:
request: HTTP request object
course_id: course id
Returns:
Paginated response as JSON. A sample response is below.
{
"count": 101,
"num_pages": 11,
"current_page": 1,
"results": [
{
"chapter": {
"index": 4,
"display_name": "About Exams and Certificates",
"location": "i4x://org/course/category/name@revision",
"children": [
"i4x://org/course/category/name@revision"
]
},
"updated": "Dec 09, 2015 at 09:31 UTC",
"tags": ["shadow","oil"],
"quote": "foo bar baz",
"section": {
"display_name": "edX Exams",
"location": "i4x://org/course/category/name@revision",
"children": [
"i4x://org/course/category/name@revision",
"i4x://org/course/category/name@revision",
]
},
"created": "2015-12-09T09:31:17.338305Z",
"ranges": [
{
"start": "/div[1]/p[1]",
"end": "/div[1]/p[1]",
"startOffset": 0,
"endOffset": 6
}
],
"user": "50cf92f9a3d8489df95e583549b919df",
"text": "first angry height hungry structure",
"course_id": "edx/DemoX/Demo",
"id": "1231",
"unit": {
"url": "/courses/edx%2FDemoX%2FDemo/courseware/1414ffd5143b4b508f739b563ab468b7/workflow/1",
"display_name": "EdX Exams",
"location": "i4x://org/course/category/name@revision"
},
"usage_id": "i4x://org/course/category/name@revision"
} ],
"next": "http://0.0.0.0:8000/courses/edx%2FDemoX%2FDemo/edxnotes/notes/?page=2&page_size=10",
"start": 0,
"previous": null
}
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, "load", course_key)
course = get_course_with_access(request.user, 'load', course_key)
if not is_feature_enabled(course):
raise Http404
if "text" not in request.GET:
return HttpResponseBadRequest()
page = request.GET.get('page') or DEFAULT_PAGE
page_size = request.GET.get('page_size') or DEFAULT_PAGE_SIZE
text = request.GET.get('text')
query_string = request.GET["text"]
try:
search_results = search(request.user, course, query_string)
notes_info = get_notes(
request,
course,
page=page,
page_size=page_size,
text=text
)
except (EdxNotesParseError, EdxNotesServiceUnavailable) as err:
return JsonResponseBadRequest({"error": err.message}, status=500)
return HttpResponse(search_results)
return HttpResponse(json.dumps(notes_info, cls=NoteJSONEncoder), content_type="application/json")
# pylint: disable=unused-argument
......
......@@ -91,6 +91,8 @@ XQUEUE_INTERFACE['url'] = 'http://localhost:8040'
EDXNOTES_PUBLIC_API = 'http://localhost:8042/api/v1'
EDXNOTES_INTERNAL_API = 'http://localhost:8042/api/v1'
NOTES_DISABLED_TABS = []
# Silence noisy logs
import logging
LOG_OVERRIDES = [
......
......@@ -2542,6 +2542,9 @@ ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = 60
if FEATURES['ENABLE_EDXNOTES']:
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
# These tabs are currently disabled
NOTES_DISABLED_TABS = ['course_structure', 'tags']
# Configuration used for generating PDF Receipts/Invoices
PDF_RECEIPT_TAX_ID = 'add here'
PDF_RECEIPT_FOOTER_TEXT = 'add your own specific footer text here'
......
......@@ -516,6 +516,8 @@ MONGODB_LOG = {
'db': 'xlog',
}
NOTES_DISABLED_TABS = []
# Enable EdxNotes for tests.
FEATURES['ENABLE_EDXNOTES'] = True
......
......@@ -34,6 +34,7 @@ var edx = edx || {};
// Emit an event when the 'course title link' is clicked.
edx.dashboard.trackCourseTitleClicked = function($courseTitleLink, properties){
var trackProperty = properties || edx.dashboard.generateTrackProperties;
window.analytics.trackLink(
$courseTitleLink,
'edx.bi.dashboard.course_title.clicked',
......@@ -102,6 +103,7 @@ var edx = edx || {};
};
edx.dashboard.xseriesTrackMessages = function() {
$('.xseries-action .btn').each(function(i, element) {
var data = edx.dashboard.generateProgramProperties($(element));
......@@ -110,6 +112,9 @@ var edx = edx || {};
};
$(document).ready(function() {
if (!window.analytics) {
return;
}
edx.dashboard.trackCourseTitleClicked($('.course-title > a'));
edx.dashboard.trackCourseImageLinkClicked($('.cover'));
edx.dashboard.trackEnterCourseLinkClicked($('.enter-course'));
......
;(function (define, undefined) {
;(function (define) {
'use strict';
define([
'backbone', 'js/edxnotes/models/note'
], function (Backbone, NoteModel) {
var NotesCollection = Backbone.Collection.extend({
'underscore', 'common/js/components/collections/paging_collection', 'js/edxnotes/models/note'
], function (_, PagingCollection, NoteModel) {
return PagingCollection.extend({
model: NoteModel,
initialize: function(models, options) {
PagingCollection.prototype.initialize.call(this);
this.perPage = options.perPage;
this.server_api = _.pick(PagingCollection.prototype.server_api, "page", "page_size");
if (options.text) {
this.server_api.text = options.text;
}
},
/**
* Returns course structure from the list of notes.
* @return {Object}
......@@ -17,30 +27,26 @@ define([
sections = {},
units = {};
if (!courseStructure) {
this.each(function (note) {
var chapter = note.get('chapter'),
section = note.get('section'),
unit = note.get('unit');
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);
});
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
};
}
courseStructure = {
chapters: _.sortBy(_.toArray(chapters), function (c) {return c.index;}),
sections: sections,
units: units
};
return courseStructure;
};
}())
});
return NotesCollection;
});
}).call(this, define || RequireJS.define);
......@@ -51,10 +51,6 @@ define(['backbone', 'js/edxnotes/utils/utils', 'underscore.string'], function (B
}
return message;
},
getText: function () {
return Utils.nl2br(this.get('text'));
}
});
......
(function (define, undefined) {
'use strict';
define(['annotator_1.2.9'], function (Annotator) {
/**
* Modifies Annotator.Plugin.Store.prototype._onError to show custom error message
* if sent by server
*/
var originalErrorHandler = Annotator.Plugin.Store.prototype._onError;
Annotator.Plugin.Store.prototype._onError = function (xhr) {
var serverResponse;
// Try to parse json
if (xhr.responseText) {
try {
serverResponse = JSON.parse(xhr.responseText);
} catch (exception) {
serverResponse = null;
}
}
// if response includes an error message it will take precedence
if (serverResponse && serverResponse.error_msg) {
Annotator.showNotification(serverResponse.error_msg, Annotator.Notification.ERROR);
return console.error(Annotator._t("API request failed:") + (" '" + xhr.status + "'"));
}
// Delegate to original error handler
originalErrorHandler(xhr);
};
});
}).call(this, define || RequireJS.define);
......@@ -31,8 +31,7 @@ define([
getContext: function () {
return $.extend({}, this.model.toJSON(), {
message: this.model.getQuote(),
text: this.model.getText()
message: this.model.getQuote()
});
},
......@@ -60,7 +59,9 @@ define([
tagHandler: function (event) {
event.preventDefault();
this.options.scrollToTag(event.currentTarget.text);
if (!_.isUndefined(this.options.scrollToTag)) {
this.options.scrollToTag(event.currentTarget.text);
}
},
redirectTo: function (uri) {
......
......@@ -4,7 +4,8 @@ define([
'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/utils/logger',
'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller',
'js/edxnotes/plugins/events', 'js/edxnotes/plugins/accessibility',
'js/edxnotes/plugins/caret_navigation'
'js/edxnotes/plugins/caret_navigation',
'js/edxnotes/plugins/store_error_handler'
], function ($, _, Annotator, NotesLogger) {
var plugins = ['Auth', 'Store', 'Scroller', 'Events', 'Accessibility', 'CaretNavigation', 'Tags'],
getOptions, setupPlugins, getAnnotator;
......
......@@ -15,17 +15,19 @@ define([
this.options = options;
this.tabsCollection = new TabsCollection();
// Must create the Tags view first to get the "scrollToTag" method.
this.tagsView = new TagsView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
if (!_.contains(this.options.disabledTabs, 'tags')) {
// Must create the Tags view first to get the "scrollToTag" method.
this.tagsView = new TagsView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
scrollToTag = this.tagsView.scrollToTag;
scrollToTag = this.tagsView.scrollToTag;
// Remove the Tags model from the tabs collection because it should not appear first.
tagsModel = this.tabsCollection.shift();
// Remove the Tags model from the tabs collection because it should not appear first.
tagsModel = this.tabsCollection.shift();
}
this.recentActivityView = new RecentActivityView({
el: this.el,
......@@ -34,20 +36,25 @@ define([
scrollToTag: scrollToTag
});
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection,
scrollToTag: scrollToTag
});
// Add the Tags model after the Course Structure model.
this.tabsCollection.push(tagsModel);
if (!_.contains(this.options.disabledTabs, 'course_structure')) {
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection,
scrollToTag: scrollToTag
});
}
if (!_.contains(this.options.disabledTabs, 'tags')) {
// Add the Tags model after the Course Structure model.
this.tabsCollection.push(tagsModel);
}
this.searchResultsView = new SearchResultsView({
el: this.el,
tabsCollection: this.tabsCollection,
debug: this.options.debug,
perPage: this.options.perPage,
createTabOnInitialization: false,
scrollToTag: scrollToTag
});
......
......@@ -6,19 +6,29 @@ define([
/**
* Factory method for the Notes page.
* @param {Object} params Params for the Notes page.
* @param {Array} params.notesList A list of note models.
* @param {List} params.disabledTabs Names of disabled tabs, these tabs will not be shown.
* @param {Object} params.notes Paginated notes info.
* @param {Number} params.pageSize Number of notes per page.
* @param {Boolean} params.debugMode Enable the flag to see debug information.
* @param {String} params.endpoint The endpoint of the store.
* @return {Object} An instance of NotesPageView.
*/
return function (params) {
var collection = new NotesCollection(params.notesList);
var collection = new NotesCollection(
params.notes,
{
url: params.notesEndpoint,
perPage: params.pageSize,
parse: true
}
);
return new NotesPageView({
el: $('.wrapper-student-notes').get(0),
collection: collection,
debug: params.debugMode,
endpoint: params.endpoint
perPage: params.pageSize,
disabledTabs: params.disabledTabs
});
};
});
......
......@@ -29,9 +29,15 @@ define([
this.logger = NotesLogger.getLogger('search_box', this.options.debug);
this.$el.removeClass('is-hidden');
this.isDisabled = false;
this.searchInput = this.$el.find('#search-notes-input');
this.logger.log('initialized');
},
clearInput: function() {
// clear the search input box
this.searchInput.val('');
},
submitHandler: function (event) {
event.preventDefault();
this.search();
......@@ -43,15 +49,12 @@ define([
* @return {Array}
*/
prepareData: function (data) {
var collection;
if (!(data && _.has(data, 'total') && _.has(data, 'rows'))) {
if (!(data && _.has(data, 'count') && _.has(data, 'results'))) {
this.logger.log('Wrong data', data, this.searchQuery);
return null;
}
collection = new NotesCollection(data.rows);
return [collection, data.total, this.searchQuery];
return [this.collection, this.searchQuery];
},
/**
......@@ -99,8 +102,8 @@ define([
if (args) {
this.options.search.apply(this, args);
this.logger.emit('edx.course.student_notes.searched', {
'number_of_results': args[1],
'search_string': args[2]
'number_of_results': args[0].totalCount,
'search_string': args[1]
});
} else {
this.options.error(this.errorMessage, this.searchQuery);
......@@ -144,15 +147,15 @@ define([
* @return {jQuery.Deferred}
*/
sendRequest: function (text) {
var settings = {
url: this.el.action,
type: this.el.method,
dataType: 'json',
data: {text: text}
};
this.logger.log(settings);
return $.ajax(settings);
this.collection = new NotesCollection(
[],
{
text: text,
perPage: this.options.perPage,
url: this.el.action
}
);
return this.collection.goTo(1);
}
});
......
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item'],
function (gettext, _, Backbone, NoteItemView) {
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item',
'common/js/components/views/paging_header', 'common/js/components/views/paging_footer'],
function (gettext, _, Backbone, NoteItemView, PagingHeaderView, PagingFooterView) {
var TabPanelView = Backbone.View.extend({
tagName: 'section',
className: 'tab-panel',
......@@ -13,14 +14,30 @@ function (gettext, _, Backbone, NoteItemView) {
initialize: function () {
this.children = [];
if (this.options.createHeaderFooter) {
this.pagingHeaderView = new PagingHeaderView({collection: this.collection});
this.pagingFooterView = new PagingFooterView({collection: this.collection, hideWhenOnePage: true});
}
if (this.hasOwnProperty('collection')) {
this.listenTo(this.collection, 'page_changed', this.render);
}
},
render: function () {
this.$el.html(this.getTitle());
this.renderView(this.pagingHeaderView);
this.renderContent();
this.renderView(this.pagingFooterView);
return this;
},
renderView: function(view) {
if (this.options.createHeaderFooter && this.collection.models.length) {
this.$el.append(view.render().el);
view.delegateEvents();
}
},
renderContent: function () {
return this;
},
......
......@@ -14,7 +14,8 @@ define([
initialize: function (options) {
_.bindAll(this, 'showLoadingIndicator', 'hideLoadingIndicator');
this.options = _.defaults(options || {}, {
createTabOnInitialization: true
createTabOnInitialization: true,
createHeaderFooter: true
});
if (this.options.createTabOnInitialization) {
......@@ -64,7 +65,13 @@ define([
getSubView: function () {
var collection = this.getCollection();
return new this.PanelConstructor({collection: collection, scrollToTag: this.options.scrollToTag});
return new this.PanelConstructor(
{
collection: collection,
scrollToTag: this.options.scrollToTag,
createHeaderFooter: this.options.createHeaderFooter
}
);
},
destroySubView: function () {
......
......@@ -58,6 +58,7 @@ define([
this.searchBox = new SearchBoxView({
el: document.getElementById('search-notes-form'),
debug: this.options.debug,
perPage: this.options.perPage,
beforeSearchStart: this.onBeforeSearchStart,
search: this.onSearch,
error: this.onSearchError
......@@ -81,7 +82,8 @@ define([
return new this.PanelConstructor({
collection: collection,
searchQuery: this.searchResults.searchQuery,
scrollToTag: this.options.scrollToTag
scrollToTag: this.options.scrollToTag,
createHeaderFooter: this.options.createHeaderFooter
});
} else {
return new this.NoResultsViewConstructor({
......@@ -103,6 +105,7 @@ define([
onClose: function () {
this.searchResults = null;
this.searchBox.clearInput();
},
onBeforeSearchStart: function () {
......@@ -122,10 +125,9 @@ define([
}
},
onSearch: function (collection, total, searchQuery) {
onSearch: function (collection, searchQuery) {
this.searchResults = {
collection: collection,
total: total,
searchQuery: searchQuery
};
......
......@@ -6,7 +6,7 @@ define([
var notes = Helpers.getDefaultNotes();
beforeEach(function () {
this.collection = new NotesCollection(notes);
this.collection = new NotesCollection(notes, {perPage: 10, parse: true});
});
it('can return correct course structure', function () {
......@@ -23,11 +23,22 @@ define([
'i4x://section/2': Helpers.getSection('First Section', 2, [3])
});
expect(structure.units).toEqual({
var compareUnits = function (structureUnits, collectionUnits) {
expect(structureUnits.length === collectionUnits.length).toBeTruthy();
for(var i = 0; i < structureUnits.length; i++) {
expect(structureUnits[i].attributes).toEqual(collectionUnits[i].attributes);
}
};
var units = {
'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)]
};
_.each(units, function(value, key){
compareUnits(structure.units[key], value);
});
});
});
......
define(['underscore'], function(_) {
define(['underscore', 'URI', 'common/js/spec_helpers/ajax_helpers'], function(_, URI, AjaxHelpers) {
'use strict';
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT,
base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes;
base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes,
verifyUrl, verifyRequestParams, createNotesData, respondToRequest,
verifyPaginationInfo, verifyPageData;
LONG_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
......@@ -106,57 +108,134 @@ define(['underscore'], function(_) {
getDefaultNotes = function () {
// Note that the server returns notes in reverse chronological order (newest first).
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',
tags: ['Pumpkin', 'pumpkin', 'yummy']
},
{
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',
tags: ['yummy']
},
{
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',
tags: ['PUMPKIN', 'pie']
},
{
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',
tags: ['pie', 'pumpkin']
}
];
return {
'count': 5,
'current_page': 1,
'num_pages': 1,
'start': 0,
'next': null,
'previous': null,
'results': [
{
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',
tags: ['Pumpkin', 'pumpkin', 'yummy']
},
{
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',
tags: ['yummy']
},
{
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',
tags: ['PUMPKIN', 'pie']
},
{
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',
tags: ['pie', 'pumpkin']
}
]
};
};
verifyUrl = function (requestUrl, expectedUrl, expectedParams) {
expect(requestUrl.slice(0, expectedUrl.length) === expectedUrl).toBeTruthy();
verifyRequestParams(requestUrl, expectedParams);
};
verifyRequestParams = function (requestUrl, expectedParams) {
var urlParams = (new URI(requestUrl)).query(true);
_.each(expectedParams, function (value, key) {
expect(urlParams[key]).toBe(value);
});
};
createNotesData = function (options) {
var data = {
count: options.count || 0,
num_pages: options.num_pages || 1,
current_page: options.current_page || 1,
start: options.start || 0,
results: []
};
for(var i = 0; i < options.numNotesToCreate; i++) {
var notesInfo = {
chapter: getChapter('First Chapter__' + i, 1, 0, [2]),
section: getSection('First Section__' + i, 2, [3]),
unit: getUnit('First Unit__' + i, 3),
created: new Date().toISOString(),
updated: new Date().toISOString(),
text: 'text__' + i,
quote: 'Note__' + i,
tags: ['tag__' + i, 'tag__' + i+1]
};
data.results.push(notesInfo);
}
return data;
};
respondToRequest = function(requests, responseJson, respondToEvent) {
// Respond to the analytics event
if (respondToEvent) {
AjaxHelpers.respondWithNoContent(requests);
}
// Now process the actual request
AjaxHelpers.respondWithJson(requests, responseJson);
};
verifyPaginationInfo = function (view, headerMessage, footerHidden, currentPage, totalPages) {
expect(view.$('.search-count.listing-count').text().trim()).toBe(headerMessage);
expect(view.$('.pagination.bottom').parent().hasClass('hidden')).toBe(footerHidden);
if (!footerHidden) {
expect(parseInt(view.$('.pagination span.current-page').text().trim())).toBe(currentPage);
expect(parseInt(view.$('.pagination span.total-pages').text().trim())).toBe(totalPages);
}
};
verifyPageData = function (view, tabsCollection, tabInfo, tabId, notes) {
expect(tabsCollection).toHaveLength(1);
expect(tabsCollection.at(0).toJSON()).toEqual(tabInfo);
expect(view.$(tabId)).toExist();
expect(view.$('.note')).toHaveLength(notes.results.length);
_.each(view.$('.note'), function(element, index) {
expect($('.note-comments', element)).toContainText(notes.results[index].text);
expect($('.note-excerpt', element)).toContainText(notes.results[index].quote);
});
};
return {
......@@ -169,6 +248,12 @@ define(['underscore'], function(_) {
getChapter: getChapter,
getSection: getSection,
getUnit: getUnit,
getDefaultNotes: getDefaultNotes
getDefaultNotes: getDefaultNotes,
verifyUrl: verifyUrl,
verifyRequestParams: verifyRequestParams,
createNotesData: createNotesData,
respondToRequest: respondToRequest,
verifyPaginationInfo: verifyPaginationInfo,
verifyPageData: verifyPageData
};
});
......@@ -4,10 +4,23 @@ define([
'use strict';
describe('EdxNotes NoteModel', function() {
beforeEach(function () {
this.collection = new NotesCollection([
{quote: Helpers.LONG_TEXT, text: 'text\n with\r\nline\n\rbreaks \r'},
{quote: Helpers.SHORT_TEXT, text: 'text\n with\r\nline\n\rbreaks \r'}
]);
this.collection = new NotesCollection(
{
'count': 2,
'current_page': 1,
'num_pages': 1,
'start': 0,
'next': null,
'previous': null,
'results': [
{quote: Helpers.LONG_TEXT, text: 'text\n with\r\nline\n\rbreaks \r'},
{quote: Helpers.SHORT_TEXT, text: 'text\n with\r\nline\n\rbreaks \r'}
]
},
{
perPage: 10, parse: true
}
);
});
it('has correct values on initialization', function () {
......@@ -33,7 +46,7 @@ define([
it('can return appropriate `text`', function () {
var model = this.collection.at(0);
expect(model.getText()).toBe('text<br> with<br>line<br>breaks <br>');
expect(model.get('text')).toBe('text\n with\r\nline\n\rbreaks \r');
});
});
});
define([
'jquery', 'underscore', 'annotator_1.2.9',
'common/js/spec_helpers/ajax_helpers',
'js/spec/edxnotes/helpers',
'js/edxnotes/views/notes_factory'
], function ($, _, Annotator, AjaxHelpers, Helpers, NotesFactory) {
'use strict';
describe('Store Error Handler Custom Message', function () {
beforeEach(function () {
spyOn(Annotator, 'showNotification');
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
this.wrapper = document.getElementById('edx-notes-wrapper-123');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('can handle custom error if sent from server', function () {
var requests = AjaxHelpers.requests(this);
var token = Helpers.makeToken();
NotesFactory.factory(this.wrapper, {
endpoint: '/test_endpoint',
user: 'a user',
usageId: 'an usage',
courseId: 'a course',
token: token,
tokenUrl: '/test_token_url'
});
var errorMsg = 'can\'t create more notes';
AjaxHelpers.respondWithError(requests, 400, {error_msg: errorMsg});
expect(Annotator.showNotification).toHaveBeenCalledWith(errorMsg, Annotator.Notification.ERROR);
});
});
});
......@@ -9,14 +9,14 @@ define([
) {
'use strict';
describe('EdxNotes NoteItemView', function() {
var getView = function (model, scrollToTag) {
var getView = function (model, scrollToTag, formattedText) {
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',
text: formattedText || 'Third added model',
quote: Helpers.LONG_TEXT,
unit: {
url: 'http://example.com/'
......@@ -67,12 +67,42 @@ define([
var view = getView({tags: ["First", "Second"]});
expect(view.$('.reference-title').length).toBe(3);
expect(view.$('.reference-title')[2]).toContainText('Tags:');
expect(view.$('a.reference-tags').length).toBe(2);
expect(view.$('a.reference-tags')[0]).toContainText('First');
expect(view.$('a.reference-tags')[1]).toContainText('Second');
expect(view.$('span.reference-tags').length).toBe(2);
expect(view.$('span.reference-tags')[0]).toContainText('First');
expect(view.$('span.reference-tags')[1]).toContainText('Second');
});
it('should handle a click event on the tag', function() {
it('should highlight tags & text if they have elasticsearch formatter', function() {
var view = getView({
tags: ["First", "{elasticsearch_highlight_start}Second{elasticsearch_highlight_end}"]
}, {}, "{elasticsearch_highlight_start}Sample{elasticsearch_highlight_end}");
expect(view.$('.reference-title').length).toBe(3);
expect(view.$('.reference-title')[2]).toContainText('Tags:');
expect(view.$('span.reference-tags').length).toBe(2);
expect(view.$('span.reference-tags')[0]).toContainText('First');
// highlighted tag & text
expect($.trim($(view.$('span.reference-tags')[1]).html())).toBe(
'<span class="note-highlight">Second</span>'
);
expect($.trim(view.$('.note-comment-p').html())).toBe('<span class="note-highlight">Sample</span>');
});
it('should escape html for tags & comments', function() {
var view = getView({
tags: ["First", "<b>Second</b>", "ȗnicode"]
}, {}, "<b>Sample</b>");
expect(view.$('.reference-title').length).toBe(3);
expect(view.$('.reference-title')[2]).toContainText('Tags:');
expect(view.$('span.reference-tags').length).toBe(3);
expect(view.$('span.reference-tags')[0]).toContainText('First');
expect($.trim($(view.$('span.reference-tags')[1]).html())).toBe(
'&lt;b&gt;Second&lt;/b&gt;'
);
expect($.trim($(view.$('span.reference-tags')[2]).html())).toBe('ȗnicode');
expect($.trim(view.$('.note-comment-p').html())).toBe('&lt;b&gt;Sample&lt;/b&gt;');
});
xit('should handle a click event on the tag', function() {
var scrollToTagSpy = {
scrollToTag: function (tagName){}
};
......
......@@ -13,7 +13,7 @@ define([
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.view = new NotesFactory({notesList: notes});
this.view = new NotesFactory({notes: notes, pageSize: 10});
});
......@@ -35,8 +35,13 @@ define([
this.view.$('.search-notes-input').val('test_query');
this.view.$('.search-notes-submit').click();
AjaxHelpers.respondWithJson(requests, {
total: 0,
rows: []
'count': 0,
'current_page': 1,
'num_pages': 1,
'start': 0,
'next': null,
'previous': null,
'results': []
});
expect(this.view.$('#view-search-results')).toHaveClass('is-active');
expect(this.view.$('#view-recent-activity')).toExist();
......
define([
'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'js/edxnotes/views/search_box',
'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers) {
'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/helpers', 'jasmine-jquery'
], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers, Helpers) {
'use strict';
describe('EdxNotes SearchBoxView', function() {
var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled;
var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled, searchResponse;
searchResponse = {
'count': 2,
'current_page': 1,
'num_pages': 1,
'start': 0,
'next': null,
'previous': null,
'results': [null, null]
};
getSearchBox = function (options) {
options = _.defaults(options || {}, {
el: $('#search-notes-form').get(0),
perPage: 10,
beforeSearchStart: jasmine.createSpy(),
search: jasmine.createSpy(),
error: jasmine.createSpy(),
......@@ -50,7 +61,11 @@ define([
submitForm(this.searchBox, 'test_text');
request = requests[0];
expect(request.method).toBe(form.method.toUpperCase());
expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'}));
Helpers.verifyUrl(
request.url,
form.action,
{text: 'test_text', page: '1', page_size: '10'}
);
});
it('returns success result', function () {
......@@ -60,13 +75,10 @@ define([
'test_text'
);
assertBoxIsDisabled(this.searchBox);
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
AjaxHelpers.respondWithJson(requests, searchResponse);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.search).toHaveBeenCalledWith(
jasmine.any(NotesCollection), 2, 'test_text'
jasmine.any(NotesCollection), 'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
......@@ -76,10 +88,7 @@ define([
it('should log the edx.course.student_notes.searched event properly', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
AjaxHelpers.respondWithJson(requests, searchResponse);
expect(Logger.log).toHaveBeenCalledWith('edx.course.student_notes.searched', {
'number_of_results': 2,
......@@ -140,10 +149,7 @@ define([
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
submitForm(this.searchBox, 'another_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
AjaxHelpers.respondWithJson(requests, searchResponse);
assertBoxIsEnabled(this.searchBox);
expect(requests).toHaveLength(1);
});
......@@ -158,5 +164,11 @@ define([
' '
);
});
it('can clear its input box', function () {
this.searchBox.$('.search-notes-input').val('search me');
this.searchBox.clearInput();
expect(this.searchBox.$('#search-notes-input').val()).toEqual('');
});
});
});
......@@ -40,7 +40,7 @@ define([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.collection = new NotesCollection(notes);
this.collection = new NotesCollection(notes, {perPage: 10, parse: true});
this.tabsCollection = new TabsCollection();
});
......
......@@ -44,7 +44,7 @@ define([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.collection = new NotesCollection(notes);
this.collection = new NotesCollection(notes, {perPage: 10, parse: true});
this.tabsCollection = new TabsCollection();
});
......
......@@ -705,6 +705,7 @@
'lms/include/js/spec/edxnotes/plugins/events_spec.js',
'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
'lms/include/js/spec/edxnotes/plugins/caret_navigation_spec.js',
'lms/include/js/spec/edxnotes/plugins/store_error_handler_spec.js',
'lms/include/js/spec/edxnotes/collections/notes_spec.js',
'lms/include/js/spec/search/search_spec.js',
'lms/include/js/spec/navigation_spec.js',
......
......@@ -189,6 +189,10 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
background: transparent;
}
.note-comment-p {
word-wrap: break-word;
}
.note-comment-ul,
.note-comment-ol {
padding: auto;
......@@ -233,29 +237,29 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
color: $m-gray-d2;
}
// CASE: tag matches a search query
.reference-meta.reference-tags .note-highlight {
// needed because .note-highlight is a span, which overrides the color
@extend %shame-link-text;
background-color: $result-highlight-color-base;
}
.reference-meta.reference-tags {
word-wrap: break-word;
// CASE: tag matches a search query
.note-highlight {
background-color: $result-highlight-color-base;
}
}
// Put commas between tags.
a.reference-meta.reference-tags:after {
span.reference-meta.reference-tags:after {
content: ",";
color: $m-gray-d2;
}
// But not after the last tag.
a.reference-meta.reference-tags:last-child:after {
span.reference-meta.reference-tags:last-child:after {
content: "";
}
// needed for poor base LMS styling scope
a.reference-meta {
@extend %shame-link-text;
@extend %shame-link-text;
}
}
}
......@@ -285,6 +289,15 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
.tab-panel, .inline-error, .ui-loading {
@extend %no-outline;
border-top: $divider-visual-primary;
.listing-tools {
@include margin($baseline $baseline (-$baseline/2) 0);
}
.note-group:first-of-type {
border-top: none;
}
}
.tab-panel.note-group {
......
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%namespace name='static' file='/static_content.html'/>
<%!
from django.utils.translation import ugettext as _
import json
from edxnotes.helpers import NoteJSONEncoder
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
%>
<%block name="bodyclass">view-student-notes is-in-course course</%block>
......@@ -12,6 +15,7 @@ import json
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='edxnotes'" />
<section class="container">
<div class="wrapper-student-notes">
<div class="student-notes">
......@@ -24,9 +28,9 @@ import json
</h1>
</div>
% if notes:
% if has_notes:
<div class="wrapper-notes-search">
<form role="search" action="${search_endpoint}" method="GET" id="search-notes-form" class="is-hidden">
<form role="search" action="${notes_endpoint}" method="GET" id="search-notes-form" class="is-hidden">
<label for="search-notes-input" class="sr">${_('Search notes for:')}</label>
<input type="search" class="search-notes-input" id="search-notes-input" name="note" placeholder="${_('Search notes for...')}" required>
<button type="submit" class="search-notes-submit">
......@@ -51,7 +55,7 @@ import json
<h2 id="tab-view" class="tabs-label">${_('View notes by:')}</h2>
</div>
% if notes:
% if has_notes:
<div class="ui-loading" tabindex="-1">
<span class="spin">
<i class="icon fa fa-refresh"></i>
......@@ -103,12 +107,15 @@ import json
% endfor
</%block>
% if notes:
% if has_notes:
<%block name="js_extra">
<%static:require_module module_name="js/edxnotes/views/page_factory" class_name="NotesPageFactory">
NotesPageFactory({
notesList: ${notes if notes is not None else []},
debugMode: ${debug}
disabledTabs: ${disabled_tabs | n, dump_js_escaped_json},
notes: ${dump_js_escaped_json(notes, NoteJSONEncoder) | n},
notesEndpoint: ${notes_endpoint | n, dump_js_escaped_json},
pageSize: ${page_size | n, dump_js_escaped_json},
debugMode: ${debug | n, dump_js_escaped_json}
});
</%static:require_module>
</%block>
......
<div class="wrapper-note-excerpts">
<% if (message) { %>
<div class="note-excerpt" role="region" aria-label="<%- gettext('Highlighted text') %>">
<p class="note-excerpt-p"><%= message %>
<p class="note-excerpt-p"><%- message %>
<% if (show_link) { %>
<% if (is_expanded) { %>
<a href="#" class="note-excerpt-more-link"><%- gettext('Less') %></a>
......@@ -17,7 +17,12 @@
<ol class="note-comments" role="region" aria-label="<%- gettext('Note') %>">
<li class="note-comment">
<p class="note-comment-title"><%- gettext("You commented...") %></p>
<p class="note-comment-p"><%= text %></p>
<p class="note-comment-p">
<%= interpolate_text(_.escape(text), {
elasticsearch_highlight_start: '<span class="note-highlight">',
elasticsearch_highlight_end: '</span>'
})%>
</p>
</li>
</ol>
<% } %>
......@@ -38,7 +43,12 @@
<% if (tags.length > 0) { %>
<p class="reference-title"><%- gettext("Tags:") %></p>
<% for (var i = 0; i < tags.length; i++) { %>
<a class="reference-meta reference-tags" href="#"><%= tags[i] %></a>
<span class="reference-meta reference-tags">
<%= interpolate_text(_.escape(tags[i]), {
elasticsearch_highlight_start: '<span class="note-highlight">',
elasticsearch_highlight_end: '</span>'
})%>
</span>
<% } %>
<% } %>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment