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

Co-Authored-By: Jean-Michel Claus <jmc@edx.org>
Co-Authored-By: Brian Talbot <btalbot@edx.org>
Co-Authored-By: Tim Babych <tim@edx.org>
Co-Authored-By: Oleg Marshev <oleg@edx.org>
Co-Authored-By: Chris Rodriguez <crodriguez@edx.org>
This commit is contained in:
polesye
2014-10-23 13:06:24 +03:00
committed by Tim Babych
parent c11a9f056e
commit c7153be040
125 changed files with 9458 additions and 358 deletions

View File

@@ -72,7 +72,7 @@ class @Calculator
.attr
'title': text
'aria-expanded': isExpanded
.text text
.find('.utility-control-label').text text
$calc.toggleClass 'closed'

View File

@@ -2,7 +2,6 @@ class @Courseware
@prefix: ''
constructor: ->
Courseware.prefix = $("meta[name='path_prefix']").attr('content')
new Navigation
Logger.bind()
@render()

View File

@@ -1,4 +1,4 @@
AjaxPrefix.addAjaxPrefix(jQuery, -> Courseware.prefix)
AjaxPrefix.addAjaxPrefix(jQuery, -> $("meta[name='path_prefix']").attr('content'))
$ ->
$.ajaxSetup

View File

@@ -0,0 +1,46 @@
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/note'
], function (Backbone, NoteModel) {
var NotesCollection = Backbone.Collection.extend({
model: NoteModel,
/**
* Returns course structure from the list of notes.
* @return {Object}
*/
getCourseStructure: (function () {
var courseStructure = null;
return function () {
var chapters = {},
sections = {},
units = {};
if (!courseStructure) {
this.each(function (note) {
var chapter = note.get('chapter'),
section = note.get('section'),
unit = note.get('unit');
chapters[chapter.location] = chapter;
sections[section.location] = section;
units[unit.location] = units[unit.location] || [];
units[unit.location].push(note);
});
courseStructure = {
chapters: _.sortBy(_.toArray(chapters), function (c) {return c.index;}),
sections: sections,
units: units
};
}
return courseStructure;
};
}())
});
return NotesCollection;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,12 @@
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/models/tab'
], function (Backbone, TabModel) {
var TabsCollection = Backbone.Collection.extend({
model: TabModel
});
return TabsCollection;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,59 @@
;(function (define) {
'use strict';
define(['backbone', 'underscore.string'], function (Backbone) {
var NoteModel = Backbone.Model.extend({
defaults: {
'id': null,
'created': '',
'updated': '',
'user': '',
'usage_id': '',
'course_id': '',
'text': '',
'quote': '',
'ranges': [],
'unit': {
'display_name': '',
'url': '',
'location': ''
},
'section': {
'display_name': '',
'location': '',
'children': []
},
'chapter': {
'display_name': '',
'location': '',
'index': 0,
'children': []
},
// Flag indicating current state of the note: expanded or collapsed.
'is_expanded': false,
// Flag indicating whether `More` link should be shown.
'show_link': false
},
textSize: 300,
initialize: function () {
if (this.get('quote').length > this.textSize) {
this.set('show_link', true);
}
},
getNoteText: function () {
var message = this.get('quote');
if (!this.get('is_expanded') && this.get('show_link')) {
message = _.str.prune(message, this.textSize);
}
return message;
}
});
return NoteModel;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,34 @@
;(function (define, undefined) {
'use strict';
define(['backbone'], function (Backbone) {
var TabModel = Backbone.Model.extend({
defaults: {
'identifier': '',
'name': '',
'icon': '',
'is_active': false,
'is_closable': false
},
activate: function () {
this.collection.each(_.bind(function(model) {
// Inactivate all other models.
if (model !== this) {
model.inactivate();
}
}, this));
this.set('is_active', true);
},
inactivate: function () {
this.set('is_active', false);
},
isActive: function () {
return this.get('is_active');
}
});
return TabModel;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,155 @@
;(function (define, undefined) {
'use strict';
define([
'underscore', 'annotator', 'underscore.string'
], function (_, Annotator) {
/**
* Modifies Annotator.Plugin.Store.annotationCreated to make it trigger a new
* event `annotationFullyCreated` when annotation is fully created and has
* an id.
*/
Annotator.Plugin.Store.prototype.annotationCreated = _.compose(
function (jqXhr) {
return jqXhr.done(_.bind(function (annotation) {
if (annotation && annotation.id){
this.publish('annotationFullyCreated', annotation);
}
}, this));
},
Annotator.Plugin.Store.prototype.annotationCreated
);
/**
* Adds the Events Plugin which emits events to capture user intent.
* Emits the following events:
* - 'edx.course.student_notes.viewed'
* [(user, note ID, datetime), (user, note ID, datetime)] - a list of notes.
* - 'edx.course.student_notes.added'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.edited'
* (user, note ID, old note text, new note text, highlighted content, ID of the component annotated, datetime)
* - 'edx.course.student_notes.deleted'
* (user, note ID, note text, highlighted content, ID of the component annotated, datetime)
**/
Annotator.Plugin.Events = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
_.extend(Annotator.Plugin.Events.prototype, new Annotator.Plugin(), {
pluginInit: function () {
_.bindAll(this,
'annotationViewerShown', 'annotationFullyCreated', 'annotationEditorShown',
'annotationEditorHidden', 'annotationUpdated', 'annotationDeleted'
);
this.annotator
.subscribe('annotationViewerShown', this.annotationViewerShown)
.subscribe('annotationFullyCreated', this.annotationFullyCreated)
.subscribe('annotationEditorShown', this.annotationEditorShown)
.subscribe('annotationEditorHidden', this.annotationEditorHidden)
.subscribe('annotationUpdated', this.annotationUpdated)
.subscribe('annotationDeleted', this.annotationDeleted);
},
destroy: function () {
this.annotator
.unsubscribe('annotationViewerShown', this.annotationViewerShown)
.unsubscribe('annotationFullyCreated', this.annotationFullyCreated)
.unsubscribe('annotationEditorShown', this.annotationEditorShown)
.unsubscribe('annotationEditorHidden', this.annotationEditorHidden)
.unsubscribe('annotationUpdated', this.annotationUpdated)
.unsubscribe('annotationDeleted', this.annotationDeleted);
},
annotationViewerShown: function (viewer, annotations) {
// Emits an event only when the annotation already exists on the
// server. Otherwise, `annotation.id` is `undefined`.
var data;
annotations = _.reject(annotations, this.isNew);
data = {
'notes': _.map(annotations, function (annotation) {
return {'note_id': annotation.id};
})
};
if (data.notes.length) {
this.log('edx.course.student_notes.viewed', data);
}
},
annotationFullyCreated: function (annotation) {
var data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.added', data);
},
annotationEditorShown: function (editor, annotation) {
this.oldNoteText = annotation.text || '';
},
annotationEditorHidden: function () {
this.oldNoteText = null;
},
annotationUpdated: function (annotation) {
var data;
if (!this.isNew(annotation)) {
data = _.extend(
this.getDefaultData(annotation),
this.getText('old_note_text', this.oldNoteText)
);
this.log('edx.course.student_notes.edited', data);
}
},
annotationDeleted: function (annotation) {
var data;
// Emits an event only when the annotation already exists on the
// server.
if (!this.isNew(annotation)) {
data = this.getDefaultData(annotation);
this.log('edx.course.student_notes.deleted', data);
}
},
getDefaultData: function (annotation) {
return _.extend(
{
'note_id': annotation.id,
'component_usage_id': annotation.usage_id
},
this.getText('note_text', annotation.text),
this.getText('highlighted_content', annotation.quote)
);
},
getText: function (fieldName, text) {
var info = {},
truncated = false,
limit = this.options.stringLimit;
if (_.isNumber(limit) && _.isString(text) && text.length > limit) {
text = String(text).slice(0, limit);
truncated = true;
}
info[fieldName] = text;
info[fieldName + '_truncated'] = truncated;
return info;
},
/**
* If the model does not yet have an id, it is considered to be new.
* @return {Boolean}
*/
isNew: function (annotation) {
return !_.has(annotation, 'id');
},
log: function (eventName, data) {
this.annotator.logger.emit(eventName, data);
}
});
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,65 @@
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
/**
* Adds the Scroller Plugin which scrolls to a note with a certain id and
* opens it.
**/
Annotator.Plugin.Scroller = function () {
// Call the Annotator.Plugin constructor this sets up the element and
// options properties.
Annotator.Plugin.apply(this, arguments);
};
$.extend(Annotator.Plugin.Scroller.prototype, new Annotator.Plugin(), {
getIdFromLocationHash: function() {
return window.location.hash.substr(1);
},
pluginInit: function () {
_.bindAll(this, 'onNotesLoaded');
// If the page URL contains a hash, we could be coming from a click
// on an anchor in the notes page. In that case, the hash is the id
// of the note that has to be scrolled to and opened.
if (this.getIdFromLocationHash()) {
this.annotator.subscribe('annotationsLoaded', this.onNotesLoaded);
}
},
destroy: function () {
this.annotator.unsubscribe('annotationsLoaded', this.onNotesLoaded);
},
onNotesLoaded: function (notes) {
var hash = this.getIdFromLocationHash();
this.annotator.logger.log('Scroller', {
'notes:': notes,
'hash': hash
});
_.each(notes, function (note) {
var highlight, offset;
if (note.id === hash && note.highlights.length) {
// Clear the page URL hash, it won't be needed once we've
// scrolled and opened the relevant note. And it would
// unnecessarily repeat the steps below if we come from
// another sequential.
window.location.hash = '';
highlight = $(note.highlights[0]);
offset = highlight.position();
// Open the note
this.annotator.showFrozenViewer([note], {
top: offset.top + 0.5 * highlight.height(),
left: offset.left + 0.5 * highlight.width()
});
// Scroll to highlight
this.scrollIntoView(highlight);
}
}, this);
},
scrollIntoView: function (highlight) {
highlight.focus();
}
});
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,150 @@
;(function (define) {
'use strict';
define(['underscore', 'logger'], function (_, Logger) {
var loggers = [],
NotesLogger, now, destroyLogger;
now = function () {
if (performance && performance.now) {
return performance.now();
} else if (Date.now) {
return Date.now();
} else {
return (new Date()).getTime();
}
};
/**
* Removes a reference on the logger from `loggers`.
* @param {Object} logger An instance of Logger.
*/
destroyLogger = function (logger) {
var index = loggers.length,
removedLogger;
while(index--) {
if (loggers[index].id === logger.id) {
removedLogger = loggers.splice(index, 1)[0];
removedLogger.historyStorage = [];
removedLogger.timeStorage = {};
break;
}
}
};
/**
* NotesLogger constructor.
* @constructor
* @param {String} id Id of the logger.
* @param {Boolean|Number} mode Outputs messages to the Web Console if true.
*/
NotesLogger = function (id, mode) {
this.id = id;
this.historyStorage = [];
this.timeStorage = {};
// 0 - silent;
// 1 - show logs;
this.logLevel = mode;
};
/**
* Outputs a message with appropriate type to the Web Console and
* store it in the history.
* @param {String} logType The type of the log message.
* @param {Arguments} args Information that will be stored.
*/
NotesLogger.prototype._log = function (logType, args) {
if (!this.logLevel) {
return false;
}
this.updateHistory.apply(this, arguments);
// Adds ID at the first place
Array.prototype.unshift.call(args, this.id);
if (console && console[logType]) {
if (console[logType].apply){
console[logType].apply(console, args);
} else { // Do this for IE
console[logType](args.join(' '));
}
}
};
/**
* Outputs a message to the Web Console and store it in the history.
*/
NotesLogger.prototype.log = function () {
this._log('log', arguments);
};
/**
* Outputs an error message to the Web Console and store it in the history.
*/
NotesLogger.prototype.error = function () {
this._log('error', arguments);
};
/**
* Adds information to the history.
*/
NotesLogger.prototype.updateHistory = function () {
this.historyStorage.push(arguments);
};
/**
* Returns the history for the logger.
* @return {Array}
*/
NotesLogger.prototype.getHistory = function () {
return this.historyStorage;
};
/**
* Starts a timer you can use to track how long an operation takes.
* @param {String} label Timer name.
*/
NotesLogger.prototype.time = function (label) {
this.timeStorage[label] = now();
};
/**
* Stops a timer that was previously started by calling NotesLogger.prototype.time().
* @param {String} label Timer name.
*/
NotesLogger.prototype.timeEnd = function (label) {
if (!this.timeStorage[label]) {
return null;
}
this._log('log', [label, now() - this.timeStorage[label], 'ms']);
delete this.timeStorage[label];
};
NotesLogger.prototype.destroy = function () {
destroyLogger(this);
};
/**
* Emits the event.
* @param {String} eventName The name of the event.
* @param {*} data Information about the event.
* @param {Number} timeout Optional timeout for the ajax request in ms.
*/
NotesLogger.prototype.emit = function (eventName, data, timeout) {
var args = [eventName, data];
this.log(eventName, data);
if (timeout) {
args.push(null, {'timeout': timeout});
}
return Logger.log.apply(Logger, args);
};
return {
getLogger: function (id, mode) {
var logger = new NotesLogger(id, mode);
loggers.push(logger);
return logger;
},
destroyLogger: destroyLogger
};
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,22 @@
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore'], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @return The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = '#' + name + '-tpl',
templateText = $(templateSelector).text();
if (!templateText) {
console.error('Failed to load ' + name + ' template');
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,70 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var NoteSectionView, NoteGroupView;
NoteSectionView = Backbone.View.extend({
tagName: 'section',
className: 'note-section',
id: function () {
return 'note-section-' + _.uniqueId();
},
template: _.template('<h4 class="course-subtitle"><%- sectionName %></h4>'),
render: function () {
this.$el.prepend(this.template({
sectionName: this.options.section.display_name
}));
return this;
},
addChild: function (child) {
this.$el.append(child);
}
});
NoteGroupView = Backbone.View.extend({
tagName: 'section',
className: 'note-group',
id: function () {
return 'note-group-' + _.uniqueId();
},
template: _.template('<h3 class="course-title"><%- chapterName %></h3>'),
initialize: function () {
this.children = [];
},
render: function () {
var container = document.createDocumentFragment();
this.$el.html(this.template({
chapterName: this.options.chapter.display_name || ''
}));
_.each(this.children, function (section) {
container.appendChild(section.render().el);
});
this.$el.append(container);
return this;
},
addChild: function (sectionInfo) {
var section = new NoteSectionView({section: sectionInfo});
this.children.push(section);
return section;
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteGroupView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,71 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore','backbone', 'js/edxnotes/utils/template',
'js/edxnotes/utils/logger'
], function ($, _, Backbone, templateUtils, NotesLogger) {
var NoteItemView = Backbone.View.extend({
tagName: 'article',
className: 'note',
id: function () {
return 'note-' + _.uniqueId();
},
events: {
'click .note-excerpt-more-link': 'moreHandler',
'click .reference-unit-link': 'unitLinkHandler',
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('note-item');
this.logger = NotesLogger.getLogger('note_item', options.debug);
this.listenTo(this.model, 'change:is_expanded', this.render);
},
render: function () {
var context = this.getContext();
this.$el.html(this.template(context));
return this;
},
getContext: function () {
return $.extend({
message: this.model.getNoteText()
}, this.model.toJSON());
},
toggleNote: function () {
var value = !this.model.get('is_expanded');
this.model.set('is_expanded', value);
},
moreHandler: function (event) {
event.preventDefault();
this.toggleNote();
},
unitLinkHandler: function (event) {
var REQUEST_TIMEOUT = 2000;
event.preventDefault();
this.logger.emit('edx.student_notes.used_unit_link', {
'note_id': this.model.get('id'),
'component_usage_id': this.model.get('usage_id')
}, REQUEST_TIMEOUT).always(_.bind(function () {
this.redirectTo(event.target.href);
}, this));
},
redirectTo: function (uri) {
window.location = uri;
},
remove: function () {
this.logger.destroy();
Backbone.View.prototype.remove.call(this);
return this;
}
});
return NoteItemView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,94 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/utils/logger',
'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller',
'js/edxnotes/plugins/events'
], function ($, _, Annotator, NotesLogger) {
var plugins = ['Auth', 'Store', 'Scroller', 'Events'],
getOptions, setupPlugins, updateHeaders, getAnnotator;
/**
* Returns options for the annotator.
* @param {jQuery Element} The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} Options.
**/
getOptions = function (element, params) {
var defaultParams = {
user: params.user,
usage_id: params.usageId,
course_id: params.courseId
},
prefix = params.endpoint.replace(/(.+)\/$/, '$1');
return {
auth: {
token: params.token,
tokenUrl: params.tokenUrl
},
events: {
stringLimit: 300
},
store: {
prefix: prefix,
annotationData: defaultParams,
loadFromSearch: defaultParams,
urls: {
create: '/annotations/',
read: '/annotations/:id/',
update: '/annotations/:id/',
destroy: '/annotations/:id/',
search: '/search/'
}
}
};
};
/**
* Setups plugins for the annotator.
* @param {Object} annotator An instance of the annotator.
* @param {Array} plugins A list of plugins for the annotator.
* @param {Object} options An options for the annotator.
**/
setupPlugins = function (annotator, plugins, options) {
_.each(plugins, function(plugin) {
var settings = options[plugin.toLowerCase()];
annotator.addPlugin(plugin, settings);
}, this);
};
/**
* Factory method that returns Annotator.js instantiates.
* @param {DOM Element} element The container element.
* @param {String} params.endpoint The endpoint of the store.
* @param {String} params.user User id of annotation owner.
* @param {String} params.usageId Usage Id of the component.
* @param {String} params.courseId Course id.
* @param {String} params.token An authentication token.
* @param {String} params.tokenUrl The URL to request the token from.
* @return {Object} An instance of Annotator.js.
**/
getAnnotator = function (element, params) {
var el = $(element),
options = getOptions(el, params),
logger = NotesLogger.getLogger(element.id, params.debug),
annotator;
annotator = el.annotator(options).data('annotator');
setupPlugins(annotator, plugins, options);
annotator.logger = logger;
logger.log({'element': element, 'options': options});
return annotator;
};
return {
factory: getAnnotator
};
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,44 @@
;(function (define, undefined) {
'use strict';
define([
'backbone', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs_list',
'js/edxnotes/views/tabs/recent_activity', 'js/edxnotes/views/tabs/course_structure',
'js/edxnotes/views/tabs/search_results'
], function (
Backbone, TabsCollection, TabsListView, RecentActivityView, CourseStructureView,
SearchResultsView
) {
var NotesPageView = Backbone.View.extend({
initialize: function (options) {
this.options = options;
this.tabsCollection = new TabsCollection();
this.recentActivityView = new RecentActivityView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
this.searchResultsView = new SearchResultsView({
el: this.el,
tabsCollection: this.tabsCollection,
debug: this.options.debug,
createTabOnInitialization: false
});
this.tabsView = new TabsListView({collection: this.tabsCollection});
this.$('.tab-list')
.append(this.tabsView.render().$el)
.removeClass('is-hidden');
}
});
return NotesPageView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,25 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'js/edxnotes/collections/notes', 'js/edxnotes/views/notes_page'
], function ($, NotesCollection, NotesPageView) {
/**
* Factory method for the Notes page.
* @param {Object} params Params for the Notes page.
* @param {Array} params.notesList A list of note models.
* @param {Boolean} params.debugMode Enable the flag to see debug information.
* @param {String} params.endpoint The endpoint of the store.
* @return {Object} An instance of NotesPageView.
*/
return function (params) {
var collection = new NotesCollection(params.notesList);
return new NotesPageView({
el: $('.wrapper-student-notes').get(0),
collection: collection,
debug: params.debugMode,
endpoint: params.endpoint
});
};
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,161 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext', 'js/edxnotes/utils/logger',
'js/edxnotes/collections/notes'
], function ($, _, Backbone, gettext, NotesLogger, NotesCollection) {
var SearchBoxView = Backbone.View.extend({
events: {
'submit': 'submitHandler'
},
errorMessage: gettext('An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.'),
emptyFieldMessage: (function () {
var message = gettext('Please enter a term in the %(anchor_start)s search field%(anchor_end)s.');
return interpolate(message, {
'anchor_start': '<a href="#search-notes-input">',
'anchor_end': '</a>'
}, true);
} ()),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError', 'onComplete');
this.options = _.defaults(options || {}, {
beforeSearchStart: function () {},
search: function () {},
error: function () {},
complete: function () {}
});
this.logger = NotesLogger.getLogger('search_box', this.options.debug);
this.$el.removeClass('is-hidden');
this.isDisabled = false;
this.logger.log('initialized');
},
submitHandler: function (event) {
event.preventDefault();
this.search();
},
/**
* Prepares server response to appropriate structure.
* @param {Object} data The response form the server.
* @return {Array}
*/
prepareData: function (data) {
var collection;
if (!(data && _.has(data, 'total') && _.has(data, 'rows'))) {
this.logger.log('Wrong data', data, this.searchQuery);
return null;
}
collection = new NotesCollection(data.rows);
return [collection, data.total, this.searchQuery];
},
/**
* Returns search text.
* @return {String}
*/
getSearchQuery: function () {
return this.$el.find('#search-notes-input').val();
},
/**
* Starts search if form is not disabled.
* @return {Boolean} Indicates if search is started or not.
*/
search: function () {
if (this.isDisabled) {
return false;
}
this.searchQuery = this.getSearchQuery();
if (!this.validateField(this.searchQuery)) {
return false;
}
this.options.beforeSearchStart(this.searchQuery);
this.disableForm();
this.sendRequest(this.searchQuery)
.done(this.onSuccess)
.fail(this.onError)
.complete(this.onComplete);
return true;
},
validateField: function (searchQuery) {
if (!($.trim(searchQuery))) {
this.options.error(this.emptyFieldMessage, searchQuery);
return false;
}
return true;
},
onSuccess: function (data) {
var args = this.prepareData(data);
if (args) {
this.options.search.apply(this, args);
this.logger.emit('edx.student_notes.searched', {
'number_of_results': args[1],
'search_string': args[2]
});
} else {
this.options.error(this.errorMessage, this.searchQuery);
}
},
onError:function (jXHR) {
var searchQuery = this.getSearchQuery(),
message;
if (jXHR.responseText) {
try {
message = $.parseJSON(jXHR.responseText).error;
} catch (error) { }
}
this.options.error(message || this.errorMessage, searchQuery);
this.logger.log('Response fails', jXHR.responseText);
},
onComplete: function () {
this.enableForm();
this.options.complete(this.searchQuery);
},
enableForm: function () {
this.isDisabled = false;
this.$el.removeClass('is-looking');
this.$('button[type=submit]').removeClass('is-disabled');
},
disableForm: function () {
this.isDisabled = true;
this.$el.addClass('is-looking');
this.$('button[type=submit]').addClass('is-disabled');
},
/**
* Sends a request with appropriate configurations.
* @param {String} text Search query.
* @return {jQuery.Deferred}
*/
sendRequest: function (text) {
var settings = {
url: this.el.action,
type: this.el.method,
dataType: 'json',
data: {text: text}
};
this.logger.log(settings);
return $.ajax(settings);
}
});
return SearchBoxView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,197 @@
;(function (define, undefined) {
'use strict';
define(['jquery', 'underscore', 'annotator'], function ($, _, Annotator) {
var _t = Annotator._t;
/**
* We currently run JQuery 1.7.2 in Jasmine tests and LMS.
* AnnotatorJS 1.2.9. uses two calls to addBack (in the two functions
* 'isAnnotator' and 'onHighlightMouseover') which was only defined in
* JQuery 1.8.0. In LMS, it works without throwing an error because
* JQuery.UI 1.10.0 adds support to jQuery<1.8 by augmenting '$.fn' with
* that missing function. It is not the case for all Jasmine unit tests,
* so we add it here if necessary.
**/
if (!$.fn.addBack) {
$.fn.addBack = function (selector) {
return this.add(
selector === null ? this.prevObject : this.prevObject.filter(selector)
);
};
}
/**
* The original _setupDynamicStyle uses a very expensive call to
* Util.maxZIndex(...) that sets the z-index of .annotator-adder,
* .annotator-outer, .annotator-notice, .annotator-filter. We set these
* values in annotator.min.css instead and do nothing here.
*/
Annotator.prototype._setupDynamicStyle = function() { };
Annotator.frozenSrc = null;
/**
* Modifies Annotator.Plugin.Auth.haveValidToken to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
return (
this._unsafeToken &&
this._unsafeToken.sub &&
this._unsafeToken.exp &&
this._unsafeToken.iat &&
this.timeToExpiry() > 0
);
};
/**
* Modifies Annotator.Plugin.Auth.timeToExpiry to make it work with a new
* token format.
**/
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
var now = new Date().getTime() / 1000,
expiry = this._unsafeToken.exp,
timeToExpiry = expiry - now;
return (timeToExpiry > 0) ? timeToExpiry : 0;
};
/**
* Modifies Annotator.highlightRange to add a "tabindex=0" attribute
* to the <span class="annotator-hl"> markup that encloses the note.
* These are then focusable via the TAB key.
**/
Annotator.prototype.highlightRange = _.compose(
function (results) {
$('.annotator-hl', this.wrapper).attr('tabindex', 0);
return results;
},
Annotator.prototype.highlightRange
);
/**
* Modifies Annotator.destroy to unbind click.edxnotes:freeze from the
* document and reset isFrozen to default value, false.
**/
Annotator.prototype.destroy = _.compose(
Annotator.prototype.destroy,
function () {
// We are destroying the instance that has the popup visible, revert to default,
// unfreeze all instances and set their isFrozen to false
if (this === Annotator.frozenSrc) {
this.unfreezeAll();
} else {
// Unfreeze only this instance and unbound associated 'click.edxnotes:freeze' handler
$(document).off('click.edxnotes:freeze' + this.uid);
this.isFrozen = false;
}
if (this.logger && this.logger.destroy) {
this.logger.destroy();
}
// Unbind onNoteClick from click
this.viewer.element.off('click', this.onNoteClick);
}
);
/**
* Modifies Annotator.Viewer.html.item template to add an i18n for the
* buttons.
**/
Annotator.Viewer.prototype.html.item = [
'<li class="annotator-annotation annotator-item">',
'<span class="annotator-controls">',
'<a href="#" title="', _t('View as webpage'), '" class="annotator-link">',
_t('View as webpage'),
'</a>',
'<button title="', _t('Edit'), '" class="annotator-edit">',
_t('Edit'),
'</button>',
'<button title="', _t('Delete'), '" class="annotator-delete">',
_t('Delete'),
'</button>',
'</span>',
'</li>'
].join('');
/**
* Modifies Annotator._setupViewer to add a "click" event on viewer.
**/
Annotator.prototype._setupViewer = _.compose(
function () {
this.viewer.element.on('click', _.bind(this.onNoteClick, this));
return this;
},
Annotator.prototype._setupViewer
);
$.extend(true, Annotator.prototype, {
events: {
'.annotator-hl click': 'onHighlightClick',
'.annotator-viewer click': 'onNoteClick'
},
isFrozen: false,
uid: _.uniqueId(),
onHighlightClick: function (event) {
Annotator.Util.preventEventDefault(event);
if (!this.isFrozen) {
event.stopPropagation();
this.onHighlightMouseover.call(this, event);
}
Annotator.frozenSrc = this;
this.freezeAll();
},
onNoteClick: function (event) {
event.stopPropagation();
Annotator.Util.preventEventDefault(event);
if (!$(event.target).is('.annotator-delete')) {
Annotator.frozenSrc = this;
this.freezeAll();
}
},
freeze: function () {
if (!this.isFrozen) {
// Remove default events
this.removeEvents();
this.viewer.element.unbind('mouseover mouseout');
this.uid = _.uniqueId();
$(document).on('click.edxnotes:freeze' + this.uid, _.bind(this.unfreeze, this));
this.isFrozen = true;
}
},
unfreeze: function () {
if (this.isFrozen) {
// Add default events
this.addEvents();
this.viewer.element.bind({
'mouseover': this.clearViewerHideTimer,
'mouseout': this.startViewerHideTimer
});
this.viewer.hide();
$(document).off('click.edxnotes:freeze'+this.uid);
this.isFrozen = false;
Annotator.frozenSrc = null;
}
},
freezeAll: function () {
_.invoke(Annotator._instances, 'freeze');
},
unfreezeAll: function () {
_.invoke(Annotator._instances, 'unfreeze');
},
showFrozenViewer: function (annotations, location) {
this.showViewer(annotations, location);
this.freezeAll();
}
});
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,57 @@
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'],
function (gettext, _, Backbone, templateUtils) {
var TabItemView = Backbone.View.extend({
tagName: 'li',
className: 'tab',
activeClassName: 'is-active',
events: {
'click': 'selectHandler',
'click a': function (event) { event.preventDefault(); },
'click .action-close': 'closeHandler'
},
initialize: function (options) {
this.template = templateUtils.loadTemplate('tab-item');
this.$el.attr('id', this.model.get('identifier'));
this.listenTo(this.model, {
'change:is_active': function (model, value) {
this.$el.toggleClass(this.activeClassName, value);
if (value) {
this.$('.tab-label').prepend($('<span />', {
'class': 'tab-aria-label sr',
'text': gettext('Current tab')
}));
} else {
this.$('.tab-aria-label').remove();
}
},
'destroy': this.remove
});
},
render: function () {
var html = this.template(this.model.toJSON());
this.$el.html(html);
return this;
},
selectHandler: function (event) {
event.preventDefault();
if (!this.model.isActive()) {
this.model.activate();
}
},
closeHandler: function (event) {
event.preventDefault();
event.stopPropagation();
this.model.destroy();
}
});
return TabItemView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,54 @@
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/views/note_item'],
function (gettext, _, Backbone, NoteItemView) {
var TabPanelView = Backbone.View.extend({
tagName: 'section',
className: 'tab-panel',
title: '',
titleTemplate: _.template('<h2 class="sr"><%- text %></h2>'),
attributes: {
'tabindex': -1
},
initialize: function () {
this.children = [];
},
render: function () {
this.$el.html(this.getTitle());
this.renderContent();
return this;
},
renderContent: function () {
return this;
},
getNotes: function (collection) {
var container = document.createDocumentFragment(),
notes = _.map(collection, function (model) {
var note = new NoteItemView({model: model});
container.appendChild(note.render().el);
return note;
});
this.children = this.children.concat(notes);
return container;
},
getTitle: function () {
return this.title ? this.titleTemplate({text: gettext(this.title)}) : '';
},
remove: function () {
_.invoke(this.children, 'remove');
this.children = null;
Backbone.View.prototype.remove.call(this);
return this;
}
});
return TabPanelView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,138 @@
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/models/tab'
], function (_, Backbone, TabModel) {
var TabView = Backbone.View.extend({
PanelConstructor: null,
tabInfo: {
name: '',
class_name: ''
},
initialize: function (options) {
_.bindAll(this, 'showLoadingIndicator', 'hideLoadingIndicator');
this.options = _.defaults(options || {}, {
createTabOnInitialization: true
});
if (this.options.createTabOnInitialization) {
this.createTab();
}
},
/**
* Creates a tab for the view.
*/
createTab: function () {
this.tabModel = new TabModel(this.tabInfo);
this.options.tabsCollection.add(this.tabModel);
this.listenTo(this.tabModel, {
'change:is_active': function (model, value) {
if (value) {
this.render();
} else {
this.destroySubView();
}
},
'destroy': function () {
this.destroySubView();
this.tabModel = null;
this.onClose();
}
});
},
/**
* Renders content for the view.
*/
render: function () {
this.hideErrorMessage().showLoadingIndicator();
// If the view is already rendered, destroy it.
this.destroySubView();
this.renderContent().always(this.hideLoadingIndicator);
return this;
},
renderContent: function () {
this.contentView = this.getSubView();
this.$('.wrapper-tabs').append(this.contentView.render().$el);
return $.Deferred().resolve().promise();
},
getSubView: function () {
var collection = this.getCollection();
return new this.PanelConstructor({collection: collection});
},
destroySubView: function () {
if (this.contentView) {
this.contentView.remove();
this.contentView = null;
}
},
/**
* Returns collection for the view.
* @return {Backbone.Collection}
*/
getCollection: function () {
return this.collection;
},
/**
* Callback that is called on closing the tab.
*/
onClose: function () { },
/**
* Returns the page's loading indicator.
*/
getLoadingIndicator: function() {
return this.$('.ui-loading');
},
/**
* Shows the page's loading indicator.
*/
showLoadingIndicator: function() {
this.getLoadingIndicator().removeClass('is-hidden');
return this;
},
/**
* Hides the page's loading indicator.
*/
hideLoadingIndicator: function() {
this.getLoadingIndicator().addClass('is-hidden');
return this;
},
/**
* Shows error message.
*/
showErrorMessage: function (message) {
this.$('.wrapper-msg')
.removeClass('is-hidden')
.find('.msg-content .copy').html(message);
return this;
},
/**
* Hides error message.
*/
hideErrorMessage: function () {
this.$('.wrapper-msg')
.addClass('is-hidden')
.find('.msg-content .copy').html('');
return this;
}
});
return TabView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,56 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'js/edxnotes/views/tab_view'
], function (gettext, NoteGroupView, TabPanelView, TabView) {
var CourseStructureView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'structure-panel',
title: 'Location in Course',
renderContent: function () {
var courseStructure = this.collection.getCourseStructure(),
container = document.createDocumentFragment();
_.each(courseStructure.chapters, function (chapterInfo) {
var group = this.getGroup(chapterInfo);
_.each(chapterInfo.children, function (location) {
var sectionInfo = courseStructure.sections[location],
section;
if (sectionInfo) {
section = group.addChild(sectionInfo);
_.each(sectionInfo.children, function (location) {
var notes = courseStructure.units[location];
if (notes) {
section.addChild(this.getNotes(notes))
}
}, this);
}
}, this);
container.appendChild(group.render().el);
}, this);
this.$el.append(container);
return this;
},
getGroup: function (chapter, section) {
var group = new NoteGroupView({
chapter: chapter,
section: section
});
this.children.push(group);
return group;
}
}),
tabInfo: {
name: gettext('Location in Course'),
identifier: 'view-course-structure',
icon: 'fa fa-list-ul'
}
});
return CourseStructureView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,31 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view'
], function (gettext, TabPanelView, TabView) {
var RecentActivityView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'recent-panel',
title: 'Recent Activity',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ')
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
tabInfo: {
identifier: 'view-recent-activity',
name: gettext('Recent Activity'),
icon: 'fa fa-clock-o'
}
});
return RecentActivityView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,148 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view',
'js/edxnotes/views/search_box'
], function (gettext, TabPanelView, TabView, SearchBoxView) {
var SearchResultsView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'search-results-panel',
title: 'Search Results',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));
return this;
}
}),
NoResultsViewConstructor: TabPanelView.extend({
id: 'no-results-panel',
title: 'No results found',
className: function () {
return [
TabPanelView.prototype.className,
'note-group'
].join(' ');
},
renderContent: function () {
var message = gettext('No results found for "%(query_string)s". Please try searching again.');
this.$el.append($('<p />', {
text: interpolate(message, {
query_string: this.options.searchQuery
}, true)
}));
return this;
}
}),
tabInfo: {
identifier: 'view-search-results',
name: gettext('Search Results'),
icon: 'fa fa-search',
is_closable: true
},
initialize: function (options) {
_.bindAll(this, 'onBeforeSearchStart', 'onSearch', 'onSearchError');
TabView.prototype.initialize.call(this, options);
this.searchResults = null;
this.searchBox = new SearchBoxView({
el: document.getElementById('search-notes-form'),
debug: this.options.debug,
beforeSearchStart: this.onBeforeSearchStart,
search: this.onSearch,
error: this.onSearchError
});
},
renderContent: function () {
this.getLoadingIndicator().focus();
return this.searchPromise.done(_.bind(function () {
this.contentView = this.getSubView();
if (this.contentView) {
this.$('.wrapper-tabs').append(this.contentView.render().$el);
}
}, this));
},
getSubView: function () {
var collection = this.getCollection();
if (collection) {
if (collection.length) {
return new this.PanelConstructor({
collection: collection,
searchQuery: this.searchResults.searchQuery
});
} else {
return new this.NoResultsViewConstructor({
searchQuery: this.searchResults.searchQuery
});
}
}
return null;
},
getCollection: function () {
if (this.searchResults) {
return this.searchResults.collection;
}
return null;
},
onClose: function () {
this.searchResults = null;
},
onBeforeSearchStart: function () {
this.searchDeferred = $.Deferred();
this.searchPromise = this.searchDeferred.promise();
this.hideErrorMessage();
this.searchResults = null;
// If tab doesn't exist, creates it.
if (!this.tabModel) {
this.createTab();
}
// If tab is not already active, makes it active
if (!this.tabModel.isActive()) {
this.tabModel.activate();
} else {
this.render();
}
},
onSearch: function (collection, total, searchQuery) {
this.searchResults = {
collection: collection,
total: total,
searchQuery: searchQuery
};
if (this.searchDeferred) {
this.searchDeferred.resolve();
}
if (this.contentView) {
this.contentView.$el.focus();
}
},
onSearchError: function (errorMessage) {
this.showErrorMessage(errorMessage);
if (this.searchDeferred) {
this.searchDeferred.reject();
}
}
});
return SearchResultsView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,41 @@
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/views/tab_item'
], function (_, Backbone, TabItemView) {
var TabsListView = Backbone.View.extend({
tagName: 'ul',
className: 'tabs',
initialize: function (options) {
this.options = options;
this.listenTo(this.collection, {
'add': this.createTab,
'destroy': function (model, collection) {
if (model.isActive() && collection.length) {
collection.at(0).activate();
}
}
});
},
render: function () {
this.collection.each(this.createTab, this);
if (this.collection.length) {
this.collection.at(0).activate();
}
return this;
},
createTab: function (model) {
var tab = new TabItemView({
model: model
});
tab.render().$el.appendTo(this.$el);
return tab;
}
});
return TabsListView;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,98 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'backbone', 'gettext',
'annotator', 'js/edxnotes/views/visibility_decorator'
], function($, _, Backbone, gettext, Annotator, EdxnotesVisibilityDecorator) {
var ToggleNotesView = Backbone.View.extend({
events: {
'click .action-toggle-notes': 'toggleHandler'
},
errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."),
initialize: function (options) {
_.bindAll(this, 'onSuccess', 'onError');
this.visibility = options.visibility;
this.visibilityUrl = options.visibilityUrl;
this.label = this.$('.utility-control-label');
this.actionLink = this.$('.action-toggle-notes');
this.actionLink.removeClass('is-disabled');
this.actionToggleMessage = this.$('.action-toggle-message');
this.notification = new Annotator.Notification();
},
toggleHandler: function (event) {
event.preventDefault();
this.visibility = !this.visibility;
this.showActionMessage();
this.toggleNotes(this.visibility);
},
toggleNotes: function (visibility) {
if (visibility) {
this.enableNotes();
} else {
this.disableNotes();
}
this.sendRequest();
},
showActionMessage: function () {
// The following lines are necessary to re-trigger the CSS animation on span.action-toggle-message
this.actionToggleMessage.removeClass('is-fleeting');
this.actionToggleMessage.offset().width = this.actionToggleMessage.offset().width;
this.actionToggleMessage.addClass('is-fleeting');
},
enableNotes: function () {
_.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote);
this.actionLink.addClass('is-active').attr('aria-pressed', true);
this.label.text(gettext('Hide notes'));
this.actionToggleMessage.text(gettext('Showing notes'));
},
disableNotes: function () {
EdxnotesVisibilityDecorator.disableNotes();
this.actionLink.removeClass('is-active').attr('aria-pressed', false);
this.label.text(gettext('Show notes'));
this.actionToggleMessage.text(gettext('Hiding notes'));
},
hideErrorMessage: function() {
this.notification.hide();
},
showErrorMessage: function(message) {
this.notification.show(message, Annotator.Notification.ERROR);
},
sendRequest: function () {
return $.ajax({
type: 'PUT',
url: this.visibilityUrl,
dataType: 'json',
data: JSON.stringify({'visibility': this.visibility}),
success: this.onSuccess,
error: this.onError
});
},
onSuccess: function () {
this.hideErrorMessage();
},
onError: function () {
this.showErrorMessage(this.errorMessage);
}
});
return function (visibility, visibilityUrl) {
return new ToggleNotesView({
el: $('.edx-notes-visibility').get(0),
visibility: visibility,
visibilityUrl: visibilityUrl
});
};
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,74 @@
;(function (define, undefined) {
'use strict';
define([
'jquery', 'underscore', 'js/edxnotes/views/notes_factory'
], function($, _, NotesFactory) {
var parameters = {}, visibility = null,
getIds, createNote, cleanup, factory;
getIds = function () {
return _.map($('.edx-notes-wrapper'), function (element) {
return element.id;
});
};
createNote = function (element, params) {
if (params) {
return NotesFactory.factory(element, params);
}
return null;
};
cleanup = function (ids) {
var list = _.clone(Annotator._instances);
ids = ids || [];
_.each(list, function (instance) {
var id = instance.element.attr('id');
if (!_.contains(ids, id)) {
instance.destroy();
}
});
};
factory = function (element, params, isVisible) {
// When switching sequentials, we need to keep track of the
// parameters of each element and the visibility (that may have been
// changed by the checkbox).
parameters[element.id] = params;
if (_.isNull(visibility)) {
visibility = isVisible;
}
if (visibility) {
// When switching sequentials, the global object Annotator still
// keeps track of the previous instances that were created in an
// array called 'Annotator._instances'. We have to destroy these
// but keep those found on page being loaded (for the case when
// there are more than one HTMLcomponent per vertical).
cleanup(getIds());
return createNote(element, params);
}
return null;
};
return {
factory: factory,
enableNote: function (element) {
createNote(element, parameters[element.id]);
visibility = true;
},
disableNotes: function () {
cleanup();
visibility = false;
},
_setVisibility: function (state) {
visibility = state;
},
}
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,48 @@
<section class="container">
<div class="wrapper-student-notes">
<div class="student-notes">
<div class="title-search-container">
<div class="wrapper-title">
<h1 class="page-title">
Notes
<small class="page-subtitle">Highlights and notes you've made in course content</small>
</h1>
</div>
<div class="wrapper-notes-search">
<form role="search" action="/search_endpoint" method="GET" id="search-notes-form" class="is-hidden">
<label for="search-notes-input" class="sr">Search notes for:</label>
<input type="search" class="search-notes-input" id="search-notes-input" name="note" placeholder="Search notes for...">
<button type="submit" class="search-notes-submit">
<i class="icon fa fa-search"></i>
<span class="sr">Search</span>
</button>
</form>
</div>
</div>
<div class="wrapper-msg is-hidden error urgency-high inline-error">
<div class="msg msg-error">
<div class="msg-content">
<p class="copy" aria-live="polite"></p>
</div>
</div>
</div>
<section class="wrapper-tabs">
<div class="tab-list is-hidden">
<h2 id="tab-view" class="tabs-label">View notes by</h2>
</div>
<div class="ui-loading" tabindex="-1">
<span class="spin">
<i class="icon fa fa-refresh"></i>
</span>
<span class="copy">Loading</span>
</div>
</section>
</div>
</div>
</section>

View File

@@ -0,0 +1,6 @@
<div id="edx-notes-wrapper-123" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>
<div id="edx-notes-wrapper-456" class="edx-notes-wrapper">
<div class="edx-notes-wrapper-content">Annotate it!</div>
</div>

View File

@@ -0,0 +1,7 @@
<div class="wrapper-utility edx-notes-visibility">
<span class="action-toggle-message">Hiding notes</span>
<button class="utility-control utility-control-button action-toggle-notes is-disabled is-active" aria-pressed="true">
<i class="icon fa fa-pencil"></i>
<span class="utility-control-label sr">Hide notes</span>
</button>
</div>

View File

@@ -0,0 +1,34 @@
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NotesCollection', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function () {
this.collection = new NotesCollection(notes);
});
it('can return correct course structure', function () {
var structure = this.collection.getCourseStructure();
expect(structure.chapters).toEqual([
Helpers.getChapter('First Chapter', 1, 0, [2]),
Helpers.getChapter('Second Chapter', 0, 1, [1, 'w_n', 0])
]);
expect(structure.sections).toEqual({
'i4x://section/0': Helpers.getSection('Third Section', 0, ['w_n', 1, 0]),
'i4x://section/1': Helpers.getSection('Second Section', 1, [2]),
'i4x://section/2': Helpers.getSection('First Section', 2, [3])
});
expect(structure.units).toEqual({
'i4x://unit/0': [this.collection.at(0), this.collection.at(1)],
'i4x://unit/1': [this.collection.at(2)],
'i4x://unit/2': [this.collection.at(3)],
'i4x://unit/3': [this.collection.at(4)]
});
});
});
});

View File

@@ -0,0 +1,32 @@
define(['jquery'], function($) {
'use strict';
return function (that) {
that.addMatchers({
toContainText: function (text) {
var trimmedText = $.trim($(this.actual).text());
if (text && $.isFunction(text.test)) {
return text.test(trimmedText);
} else {
return trimmedText.indexOf(text) !== -1;
}
},
toHaveLength: function (number) {
return $(this.actual).length === number;
},
toHaveIndex: function (number) {
return $(this.actual).index() === number;
},
toBeInRange: function (min, max) {
return min <= this.actual && this.actual <= max;
},
toBeFocused: function () {
return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement;
}
});
};
});

View File

@@ -0,0 +1,169 @@
define(['underscore'], function(_) {
'use strict';
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
LONG_TEXT, PRUNED_TEXT, TRUNCATED_TEXT, SHORT_TEXT,
base64Encode, makeToken, getChapter, getSection, getUnit, getDefaultNotes;
LONG_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Excepteur ',
'sint occaecat cupidatat non proident, sunt in culpa ',
'qui officia deserunt mollit anim id est laborum.'
].join('');
PRUNED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur...'
].join('');
TRUNCATED_TEXT = [
'Adipisicing elit, sed do eiusmod tempor incididunt ',
'ut labore et dolore magna aliqua. Ut enim ad minim ',
'veniam, quis nostrud exercitation ullamco laboris ',
'nisi ut aliquip ex ea commodo consequat. Duis aute ',
'irure dolor in reprehenderit in voluptate velit esse ',
'cillum dolore eu fugiat nulla pariatur. Exce'
].join('');
SHORT_TEXT = 'Adipisicing elit, sed do eiusmod tempor incididunt';
base64Encode = function (data) {
var ac, bits, enc, h1, h2, h3, h4, i, o1, o2, o3, r, tmp_arr;
if (btoa) {
// Gecko and Webkit provide native code for this
return btoa(data);
} else {
// Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_encode
// version 1109.2015
i = 0;
ac = 0;
enc = "";
tmp_arr = [];
if (!data) {
return data;
}
data += '';
while (i < data.length) {
o1 = data.charCodeAt(i++);
o2 = data.charCodeAt(i++);
o3 = data.charCodeAt(i++);
bits = o1 << 16 | o2 << 8 | o3;
h1 = bits >> 18 & 0x3f;
h2 = bits >> 12 & 0x3f;
h3 = bits >> 6 & 0x3f;
h4 = bits & 0x3f;
tmp_arr[ac++] = B64.charAt(h1) + B64.charAt(h2) + B64.charAt(h3) + B64.charAt(h4);
}
enc = tmp_arr.join('');
r = data.length % 3;
return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
}
};
makeToken = function() {
var now = (new Date()).getTime() / 1000,
rawToken = {
sub: "sub",
exp: now + 100,
iat: now
};
return 'header.' + base64Encode(JSON.stringify(rawToken)) + '.signature';
};
getChapter = function (name, location, index, children) {
return {
display_name: name,
location: 'i4x://chapter/' + location,
index: index,
children: _.map(children, function (i) {
return 'i4x://section/' + i;
})
};
};
getSection = function (name, location, children) {
return {
display_name: name,
location: 'i4x://section/' + location,
children: _.map(children, function (i) {
return 'i4x://unit/' + i;
})
};
};
getUnit = function (name, location) {
return {
display_name: name,
location: 'i4x://unit/' + location,
url: 'http://example.com'
};
};
getDefaultNotes = function () {
return [
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Note 4'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Fourth Unit', 0),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Third added model',
quote: 'Note 5'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Third Section', 0, ['w_n', 1, 0]),
unit: getUnit('Third Unit', 1),
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Note 3'
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
section: getSection('Second Section', 1, [2]),
unit: getUnit('Second Unit', 2),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 2'
},
{
chapter: getChapter('First Chapter', 1, 0, [2]),
section: getSection('First Section', 2, [3]),
unit: getUnit('First Unit', 3),
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 1'
}
];
};
return {
LONG_TEXT: LONG_TEXT,
PRUNED_TEXT: PRUNED_TEXT,
TRUNCATED_TEXT: TRUNCATED_TEXT,
SHORT_TEXT: SHORT_TEXT,
base64Encode: base64Encode,
makeToken: makeToken,
getChapter: getChapter,
getSection: getSection,
getUnit: getUnit,
getDefaultNotes: getDefaultNotes
};
});

View File

@@ -0,0 +1,34 @@
define([
'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes'
], function(Helpers, NotesCollection) {
'use strict';
describe('EdxNotes NoteModel', function() {
beforeEach(function () {
this.collection = new NotesCollection([
{quote: Helpers.LONG_TEXT},
{quote: Helpers.SHORT_TEXT}
]);
});
it('has correct values on initialization', function () {
expect(this.collection.at(0).get('is_expanded')).toBeFalsy();
expect(this.collection.at(0).get('show_link')).toBeTruthy();
expect(this.collection.at(1).get('is_expanded')).toBeFalsy();
expect(this.collection.at(1).get('show_link')).toBeFalsy();
});
it('can return appropriate note text', function () {
var model = this.collection.at(0);
// is_expanded = false, show_link = true
expect(model.getNoteText()).toBe(Helpers.PRUNED_TEXT);
model.set('is_expanded', true);
// is_expanded = true, show_link = true
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
model.set('show_link', false);
model.set('is_expanded', false);
// is_expanded = false, show_link = false
expect(model.getNoteText()).toBe(Helpers.LONG_TEXT);
});
});
});

View File

@@ -0,0 +1,33 @@
define([
'js/edxnotes/collections/tabs'
], function(TabsCollection) {
'use strict';
describe('EdxNotes TabModel', function() {
beforeEach(function () {
this.collection = new TabsCollection([{}, {}, {}]);
});
it('when activate current model, all other models are inactivated', function () {
this.collection.at(1).activate();
expect(this.collection.at(1).get('is_active')).toBeTruthy();
expect(this.collection.at(0).get('is_active')).toBeFalsy();
expect(this.collection.at(2).get('is_active')).toBeFalsy();
});
it('can inactivate current model', function () {
var model = this.collection.at(0);
model.activate();
expect(model.get('is_active')).toBeTruthy();
model.inactivate();
expect(model.get('is_active')).toBeFalsy();
});
it('can see correct activity status via isActive', function () {
var model = this.collection.at(0);
model.activate();
expect(model.isActive()).toBeTruthy();
model.inactivate();
expect(model.isActive()).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,158 @@
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'annotator', 'logger', 'js/edxnotes/views/notes_factory'
], function($, _, AjaxHelpers, Helpers, Annotator, Logger, NotesFactory) {
'use strict';
describe('EdxNotes Events Plugin', function() {
var note = {
user: 'user-123',
id: 'note-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
},
noteWithoutId = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
beforeEach(function() {
this.annotator = NotesFactory.factory(
$('<div />').get(0), {
endpoint: 'http://example.com/'
}
);
spyOn(Logger, 'log');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should log edx.course.student_notes.viewed event properly', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer,
[note, {user: 'user-456'}, {user: 'user-789', id: 'note-789'}]
]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.viewed', {
'notes': [{'note_id': 'note-123'}, {'note_id': 'note-789'}]
}
);
});
it('should not log edx.course.student_notes.viewed event if all notes are new', function() {
this.annotator.publish('annotationViewerShown', [
this.annotator.viewer, [{user: 'user-456'}, {user: 'user-789'}]
]);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should log edx.course.student_notes.added event properly', function() {
var requests = AjaxHelpers.requests(this),
newNote = {
user: 'user-123',
text: 'text-123',
quote: 'quote-123',
usage_id: 'usage-123'
};
this.annotator.publish('annotationCreated', newNote);
AjaxHelpers.respondWithJson(requests, note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.added', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should log the edx.course.student_notes.edited event properly', function() {
var oldNote = note,
newNote = $.extend({}, note, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': 'text-123',
'old_note_text_truncated': false,
'note_text': 'text-456',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should not log the edx.course.student_notes.edited event if the note is new', function() {
var oldNote = noteWithoutId,
newNote = $.extend({}, noteWithoutId, {text: 'text-456'});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe('text-123');
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).not.toHaveBeenCalled();
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
it('should log the edx.course.student_notes.deleted event properly', function() {
this.annotator.publish('annotationDeleted', note);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.deleted', {
'note_id': 'note-123',
'note_text': 'text-123',
'note_text_truncated': false,
'highlighted_content': 'quote-123',
'highlighted_content_truncated': false,
'component_usage_id': 'usage-123'
}
);
});
it('should not log the edx.course.student_notes.deleted event if the note is new', function() {
this.annotator.publish('annotationDeleted', noteWithoutId);
expect(Logger.log).not.toHaveBeenCalled();
});
it('should truncate values of some fields', function() {
var oldNote = $.extend({}, note, {text: Helpers.LONG_TEXT}),
newNote = $.extend({}, note, {
text: Helpers.LONG_TEXT + '123',
quote: Helpers.LONG_TEXT + '123'
});
this.annotator.publish('annotationEditorShown', [this.annotator.editor, oldNote]);
expect(this.annotator.plugins.Events.oldNoteText).toBe(Helpers.LONG_TEXT);
this.annotator.publish('annotationUpdated', newNote);
this.annotator.publish('annotationEditorHidden', [this.annotator.editor, newNote]);
expect(Logger.log).toHaveBeenCalledWith(
'edx.course.student_notes.edited', {
'note_id': 'note-123',
'old_note_text': Helpers.TRUNCATED_TEXT,
'old_note_text_truncated': true,
'note_text': Helpers.TRUNCATED_TEXT,
'note_text_truncated': true,
'highlighted_content': Helpers.TRUNCATED_TEXT,
'highlighted_content_truncated': true,
'component_usage_id': 'usage-123'
}
);
expect(this.annotator.plugins.Events.oldNoteText).toBeNull();
});
});
});

View File

@@ -0,0 +1,94 @@
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory',
'js/spec/edxnotes/custom_matchers'
], function($, _, Annotator, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes Scroll Plugin', function() {
var annotators, highlights;
function checkAnnotatorIsFrozen(annotator) {
expect(annotator.isFrozen).toBe(true);
expect(annotator.onHighlightMouseover).not.toHaveBeenCalled();
expect(annotator.startViewerHideTimer).not.toHaveBeenCalled();
}
function checkAnnotatorIsUnfrozen(annotator) {
expect(annotator.isFrozen).toBe(false);
expect(annotator.onHighlightMouseover).toHaveBeenCalled();
expect(annotator.startViewerHideTimer).toHaveBeenCalled();
}
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
annotators = [
NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), {
endpoint: 'http://example.com/'
}),
NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), {
endpoint: 'http://example.com/'
})
];
highlights = _.map(annotators, function(annotator) {
spyOn(annotator, 'onHighlightClick').andCallThrough();
spyOn(annotator, 'onHighlightMouseover').andCallThrough();
spyOn(annotator, 'startViewerHideTimer').andCallThrough();
return $('<span></span>', {
'class': 'annotator-hl',
'tabindex': -1,
'text': 'some content'
}).appendTo(annotator.element);
});
spyOn(annotators[0].plugins.Scroller, 'getIdFromLocationHash').andReturn('abc123');
spyOn($.fn, 'unbind').andCallThrough();
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('should scroll to a note, open it and freeze the annotator if its id is part of the url hash', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'abc123',
highlights: [highlights[0]]
}]);
annotators[0].onHighlightMouseover.reset();
expect(highlights[0]).toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsFrozen(annotators[0]);
});
it('should not do anything if the url hash contains a wrong id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: 'def456',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should not do anything if the url hash contains an empty id', function() {
annotators[0].plugins.Scroller.onNotesLoaded([{
id: '',
highlights: [highlights[0]]
}]);
expect(highlights[0]).not.toBeFocused();
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsUnfrozen(annotators[0]);
});
it('should unbind onNotesLoaded on destruction', function() {
annotators[0].plugins.Scroller.destroy();
expect($.fn.unbind).toHaveBeenCalledWith(
'annotationsLoaded',
annotators[0].plugins.Scroller.onNotesLoaded
);
});
});
});

View File

@@ -0,0 +1,124 @@
define([
'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers'
], function(Logger, NotesLogger, customMatchers) {
'use strict';
describe('Edxnotes NotesLogger', function() {
var getLogger = function(id, mode) {
return NotesLogger.getLogger(id, mode);
};
beforeEach(function () {
spyOn(window.console, 'log');
spyOn(window.console, 'error');
spyOn(Logger, 'log');
customMatchers(this);
});
it('keeps a correct history of logs', function() {
var logger = getLogger('id', 1),
logs, log;
logger.log('A log type', 'A first log');
logger.log('A log type', 'A second log');
expect(window.console.log).toHaveBeenCalled();
logs = logger.getHistory();
// Test first log
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A first log');
// Test second log
log = logs[1];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('A log type');
expect(log[1][2]).toBe('A second log');
});
it('keeps a correct history of errors', function() {
var logger = getLogger('id', 1),
logs, log;
logger.error('An error type', 'A first error');
logger.error('An error type', 'A second error');
expect(window.console.error).toHaveBeenCalled();
logs = logger.getHistory();
// Test first error
log = logs[0];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A first error');
// Test second error
log = logs[1];
expect(log[0]).toBe('error');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('An error type');
expect(log[1][2]).toBe('A second error');
});
it('can destroy the logger', function() {
var logger = getLogger('id', 1),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(2);
logger.destroy();
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not store the history in silent mode', function() {
var logger = getLogger('id', 0),
logs;
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
logs = logger.getHistory();
expect(logs.length).toBe(0);
});
it('do not show logs in the console in silent mode', function() {
var logger = getLogger('id', 0);
logger.log('A log type', 'A first log');
logger.error('An error type', 'A first error');
expect(window.console.log).not.toHaveBeenCalled();
expect(window.console.error).not.toHaveBeenCalled();
});
it('can use timers', function() {
var logger = getLogger('id', 1),
now, t0, logs, log;
now = function () {
return (new Date()).getTime();
};
t0 = now();
logger.time('timer');
while (now() - t0 < 200) {}
logger.timeEnd('timer');
logs = logger.getHistory();
log = logs[0];
expect(log[0]).toBe('log');
expect(log[1][0]).toBe('id');
expect(log[1][1]).toBe('timer');
expect(log[1][2]).toBeInRange(180, 220);
expect(log[1][3]).toBe('ms');
});
it('can emit an event properly', function () {
var logger = getLogger('id', 0);
logger.emit('event_name', {id: 'some_id'})
expect(Logger.log).toHaveBeenCalledWith('event_name', {
id: 'some_id'
});
});
});
});

View File

@@ -0,0 +1,81 @@
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers',
'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'logger',
'js/edxnotes/models/note', 'js/edxnotes/views/note_item',
'js/spec/edxnotes/custom_matchers'
], function(
$, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView,
customMatchers
) {
'use strict';
describe('EdxNotes NoteItemView', function() {
var getView = function (model) {
model = new NoteModel(_.defaults(model || {}, {
id: 'id-123',
user: 'user-123',
usage_id: 'usage_id-123',
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: Helpers.LONG_TEXT,
unit: {
url: 'http://example.com/'
}
}));
return new NoteItemView({model: model}).render();
};
beforeEach(function() {
customMatchers(this);
TemplateHelpers.installTemplate('templates/edxnotes/note-item');
spyOn(Logger, 'log').andCallThrough();
});
it('can be rendered properly', function() {
var view = getView(),
unitLink = view.$('.reference-unit-link').get(0);
expect(view.$el).toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.PRUNED_TEXT);
expect(view.$el).toContainText('More');
view.$('.note-excerpt-more-link').click();
expect(view.$el).toContainText(Helpers.LONG_TEXT);
expect(view.$el).toContainText('Less');
view = getView({quote: Helpers.SHORT_TEXT});
expect(view.$el).not.toContain('.note-excerpt-more-link');
expect(view.$el).toContainText(Helpers.SHORT_TEXT);
expect(unitLink.hash).toBe('#id-123');
});
it('should display update value and accompanying text', function() {
var view = getView();
expect(view.$('.reference-title').last()).toContainText('Last Edited:');
expect(view.$('.reference-meta').last()).toContainText('December 11, 2014 at 11:12AM');
});
it('should log the edx.student_notes.used_unit_link event properly', function () {
var requests = AjaxHelpers.requests(this),
view = getView();
spyOn(view, 'redirectTo');
view.$('.reference-unit-link').click();
expect(Logger.log).toHaveBeenCalledWith(
'edx.student_notes.used_unit_link',
{
'note_id': 'id-123',
'component_usage_id': 'usage_id-123'
},
null,
{
'timeout': 2000
}
);
expect(view.redirectTo).not.toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(view.redirectTo).toHaveBeenCalledWith('http://example.com/#id-123');
});
});
});

View File

@@ -0,0 +1,43 @@
define([
'annotator', 'js/edxnotes/views/notes_factory', 'js/common_helpers/ajax_helpers',
'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers'
], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) {
'use strict';
describe('EdxNotes NotesFactory', function() {
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
this.wrapper = document.getElementById('edx-notes-wrapper-123');
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('can initialize annotator correctly', function() {
var requests = AjaxHelpers.requests(this),
token = Helpers.makeToken(),
options = {
user: 'a user',
usage_id : 'an usage',
course_id: 'a course'
},
annotator = NotesFactory.factory(this.wrapper, {
endpoint: '/test_endpoint',
user: 'a user',
usageId : 'an usage',
courseId: 'a course',
token: token,
tokenUrl: '/test_token_url'
}),
request = requests[0];
expect(requests).toHaveLength(1);
expect(request.requestHeaders['x-annotator-auth-token']).toBe(token);
expect(annotator.options.auth.tokenUrl).toBe('/test_token_url');
expect(annotator.options.store.prefix).toBe('/test_endpoint');
expect(annotator.options.store.annotationData).toEqual(options);
expect(annotator.options.store.loadFromSearch).toEqual(options);
});
});
});

View File

@@ -0,0 +1,46 @@
define([
'jquery', 'underscore', 'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers', 'js/spec/edxnotes/helpers',
'js/edxnotes/views/page_factory', 'js/spec/edxnotes/custom_matchers'
], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory, customMatchers) {
'use strict';
describe('EdxNotes NotesPage', function() {
var notes = Helpers.getDefaultNotes();
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.view = new NotesFactory({notesList: notes});
});
it('should be displayed properly', function() {
var requests = AjaxHelpers.requests(this),
tab;
expect(this.view.$('#view-search-results')).not.toExist();
tab = this.view.$('#view-recent-activity');
expect(tab).toHaveClass('is-active');
expect(tab.index()).toBe(0);
tab = this.view.$('#view-course-structure');
expect(tab).toExist();
expect(tab.index()).toBe(1);
expect(this.view.$('.tab-panel')).toExist();
this.view.$('.search-notes-input').val('test_query');
this.view.$('.search-notes-submit').click();
AjaxHelpers.respondWithJson(requests, {
total: 0,
rows: []
});
expect(this.view.$('#view-search-results')).toHaveClass('is-active');
expect(this.view.$('#view-recent-activity')).toExist();
expect(this.view.$('#view-course-structure')).toExist();
});
});
});

View File

@@ -0,0 +1,162 @@
define([
'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/search_box',
'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers) {
'use strict';
describe('EdxNotes SearchBoxView', function() {
var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled;
getSearchBox = function (options) {
options = _.defaults(options || {}, {
el: $('#search-notes-form').get(0),
beforeSearchStart: jasmine.createSpy(),
search: jasmine.createSpy(),
error: jasmine.createSpy(),
complete: jasmine.createSpy()
});
return new SearchBoxView(options);
};
submitForm = function (searchBox, text) {
searchBox.$('.search-notes-input').val(text);
searchBox.$('.search-notes-submit').click();
};
assertBoxIsEnabled = function (searchBox) {
expect(searchBox.$el).not.toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).not.toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeFalsy();
};
assertBoxIsDisabled = function (searchBox) {
expect(searchBox.$el).toHaveClass('is-looking');
expect(searchBox.$('.search-notes-submit')).toHaveClass('is-disabled');
expect(searchBox.isDisabled).toBeTruthy();
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
spyOn(Logger, 'log');
this.searchBox = getSearchBox();
});
it('sends a request with proper information on submit the form', function () {
var requests = AjaxHelpers.requests(this),
form = this.searchBox.el,
request;
submitForm(this.searchBox, 'test_text');
request = requests[0];
expect(request.method).toBe(form.method.toUpperCase());
expect(request.url).toBe(form.action + '?' + $.param({text: 'test_text'}));
});
it('returns success result', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
expect(this.searchBox.options.beforeSearchStart).toHaveBeenCalledWith(
'test_text'
);
assertBoxIsDisabled(this.searchBox);
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.search).toHaveBeenCalledWith(
jasmine.any(NotesCollection), 2, 'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('should log the edx.student_notes.searched event properly', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
expect(Logger.log).toHaveBeenCalledWith('edx.student_notes.searched', {
'number_of_results': 2,
'search_string': 'test_text'
});
});
it('returns default error message if received data structure is wrong', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithJson(requests, {});
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns default error message if network error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
AjaxHelpers.respondWithError(requests);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('returns error message if server error occurs', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
requests[0].respond(
500, {'Content-Type': 'application/json'},
JSON.stringify({
error: 'test error message'
})
);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'test error message',
'test_text'
);
expect(this.searchBox.options.complete).toHaveBeenCalledWith(
'test_text'
);
});
it('does not send second request during current search', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, 'test_text');
assertBoxIsDisabled(this.searchBox);
submitForm(this.searchBox, 'another_text');
AjaxHelpers.respondWithJson(requests, {
total: 2,
rows: [null, null]
});
assertBoxIsEnabled(this.searchBox);
expect(requests).toHaveLength(1);
});
it('returns error message if the field is empty', function () {
var requests = AjaxHelpers.requests(this);
submitForm(this.searchBox, ' ');
expect(requests).toHaveLength(0);
assertBoxIsEnabled(this.searchBox);
expect(this.searchBox.options.error).toHaveBeenCalledWith(
'Please enter a term in the <a href="#search-notes-input"> search field</a>.',
' '
);
});
});
});

View File

@@ -0,0 +1,125 @@
define([
'jquery', 'underscore', 'annotator', 'js/edxnotes/views/notes_factory', 'jasmine-jquery'
], function($, _, Annotator, NotesFactory) {
'use strict';
describe('EdxNotes Shim', function() {
var annotators, highlights;
function checkAnnotatorIsFrozen(annotator) {
expect(annotator.isFrozen).toBe(true);
expect(annotator.onHighlightMouseover).not.toHaveBeenCalled();
expect(annotator.startViewerHideTimer).not.toHaveBeenCalled();
}
function checkAnnotatorIsUnfrozen(annotator) {
expect(annotator.isFrozen).toBe(false);
expect(annotator.onHighlightMouseover).toHaveBeenCalled();
expect(annotator.startViewerHideTimer).toHaveBeenCalled();
}
function checkClickEventsNotBound(namespace) {
var events = $._data(document, 'events').click;
_.each(events, function(event) {
expect(event.namespace.indexOf(namespace)).toBe(-1);
});
}
beforeEach(function() {
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
highlights = [];
annotators = [
NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), {
endpoint: 'http://example.com/'
}),
NotesFactory.factory($('div#edx-notes-wrapper-456').get(0), {
endpoint: 'http://example.com/'
})
];
_.each(annotators, function(annotator, index) {
highlights.push($('<span class="annotator-hl" />').appendTo(annotators[index].element));
spyOn(annotator, 'onHighlightClick').andCallThrough();
spyOn(annotator, 'onHighlightMouseover').andCallThrough();
spyOn(annotator, 'startViewerHideTimer').andCallThrough();
});
spyOn($.fn, 'off').andCallThrough();
});
afterEach(function () {
_.invoke(Annotator._instances, 'destroy');
});
it('clicking a highlight freezes mouseover and mouseout in all highlighted text', function() {
_.each(annotators, function(annotator) {
expect(annotator.isFrozen).toBe(false);
});
highlights[0].click();
// Click is attached to the onHighlightClick event handler which
// in turn calls onHighlightMouseover.
// To test if onHighlightMouseover is called or not on
// mouseover, we'll have to reset onHighlightMouseover.
expect(annotators[0].onHighlightClick).toHaveBeenCalled();
expect(annotators[0].onHighlightMouseover).toHaveBeenCalled();
annotators[0].onHighlightMouseover.reset();
// Check that both instances of annotator are frozen
_.invoke(highlights, 'mouseover');
_.invoke(highlights, 'mouseout');
_.each(annotators, checkAnnotatorIsFrozen);
});
it('clicking twice reverts to default behavior', function() {
highlights[0].click();
$(document).click();
annotators[0].onHighlightMouseover.reset();
// Check that both instances of annotator are unfrozen
_.invoke(highlights, 'mouseover');
_.invoke(highlights, 'mouseout');
_.each(annotators, function(annotator) {
checkAnnotatorIsUnfrozen(annotator);
});
});
it('destroying an instance with an open viewer sets all other instances' +
'to unfrozen and unbinds document click.edxnotes:freeze event handlers', function() {
// Freeze all instances
highlights[0].click();
// Destroy first instance
annotators[0].destroy();
// Check that all click.edxnotes:freeze are unbound
checkClickEventsNotBound('edxnotes:freeze');
// Check that the remaining instance is unfrozen
highlights[1].mouseover();
highlights[1].mouseout();
checkAnnotatorIsUnfrozen(annotators[1]);
});
it('destroying an instance with an closed viewer only unfreezes that instance' +
'and unbinds one document click.edxnotes:freeze event handlers', function() {
// Freeze all instances
highlights[0].click();
annotators[0].onHighlightMouseover.reset();
// Destroy second instance
annotators[1].destroy();
// Check that the first instance is frozen
highlights[0].mouseover();
highlights[0].mouseout();
checkAnnotatorIsFrozen(annotators[0]);
// Check that second one doesn't have a bound click.edxnotes:freeze
checkClickEventsNotBound('edxnotes:freeze' + annotators[1].uid);
});
it('should unbind onNotesLoaded on destruction', function() {
annotators[0].destroy();
expect($.fn.off).toHaveBeenCalledWith(
'click',
annotators[0].onNoteClick
);
});
});
});

View File

@@ -0,0 +1,54 @@
define([
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) {
'use strict';
describe('EdxNotes TabItemView', function() {
beforeEach(function () {
customMatchers(this);
TemplateHelpers.installTemplate('templates/edxnotes/tab-item');
this.collection = new TabsCollection([
{identifier: 'first-item'},
{
identifier: 'second-item',
is_closable: true,
icon: 'icon-class'
}
]);
this.tabsList = new TabsListView({
collection: this.collection
}).render();
});
it('can contain an icon', function () {
var firstItem = this.tabsList.$('#first-item'),
secondItem = this.tabsList.$('#second-item');
expect(firstItem.find('.icon')).not.toExist();
expect(secondItem.find('.icon')).toHaveClass('icon-class');
});
it('can navigate between tabs', function () {
var firstItem = this.tabsList.$('#first-item'),
secondItem = this.tabsList.$('#second-item');
expect(firstItem).toHaveClass('is-active'); // first tab is active
expect(firstItem).toContainText('Current tab');
expect(secondItem).not.toHaveClass('is-active'); // second tab is not active
expect(secondItem).not.toContainText('Current tab');
secondItem.click();
expect(firstItem).not.toHaveClass('is-active'); // first tab is not active
expect(firstItem).not.toContainText('Current tab');
expect(secondItem).toHaveClass('is-active'); // second tab is active
expect(secondItem).toContainText('Current tab');
});
it('can close the tab', function () {
var secondItem = this.tabsList.$('#second-item');
expect(this.tabsList.$('.tab')).toHaveLength(2);
secondItem.find('.action-close').click();
expect(this.tabsList.$('.tab')).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,117 @@
define([
'jquery', 'backbone', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
'js/edxnotes/views/tabs_list', 'js/edxnotes/views/tab_view',
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function(
$, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView, customMatchers
) {
'use strict';
describe('EdxNotes TabView', function() {
var TestSubView = Backbone.View.extend({
id: 'test-subview-panel',
className: 'tab-panel',
content: '<p>test view content</p>',
render: function () {
this.$el.html(this.content);
return this;
}
}),
TestView = TabView.extend({
PanelConstructor: TestSubView,
tabInfo: {
name: 'Test View Tab',
is_closable: true
}
}), getView;
getView = function (tabsCollection, options) {
var view;
options = _.defaults(options || {}, {
el: $('.wrapper-student-notes'),
collection: [],
tabsCollection: tabsCollection
});
view = new TestView(options);
if (tabsCollection.length) {
tabsCollection.at(0).activate();
}
return view;
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.tabsCollection = new TabsCollection();
this.tabsList = new TabsListView({collection: this.tabsCollection}).render();
this.tabsList.$el.appendTo($('.tab-list'));
});
it('can create a tab and content on initialization', function () {
var view = getView(this.tabsCollection);
expect(this.tabsCollection).toHaveLength(1);
expect(view.$('.tab')).toExist();
expect(view.$('.wrapper-tabs')).toContainHtml('<p>test view content</p>');
});
it('cannot create a tab on initialization if flag is not set', function () {
var view = getView(this.tabsCollection, {
createTabOnInitialization: false
});
expect(this.tabsCollection).toHaveLength(0);
expect(view.$('.tab')).not.toExist();
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
});
it('can remove the content if tab becomes inactive', function () {
var view = getView(this.tabsCollection);
this.tabsCollection.add({identifier: 'second-tab'});
view.$('#second-tab').click();
expect(view.$('.tab')).toHaveLength(2);
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
});
it('can remove the content if tab is closed', function () {
var view = getView(this.tabsCollection);
view.onClose = jasmine.createSpy();
view.$('.tab .action-close').click();
expect(view.$('.tab')).toHaveLength(0);
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
expect(view.tabModel).toBeNull();
expect(view.onClose).toHaveBeenCalled();
});
it('can correctly update the content of active tab', function () {
var view = getView(this.tabsCollection);
TestSubView.prototype.content = '<p>New content</p>';
view.render();
expect(view.$('.wrapper-tabs')).toContainHtml('<p>New content</p>');
expect(view.$('.wrapper-tabs')).not.toContainHtml('<p>test view content</p>');
});
it('can show/hide error messages', function () {
var view = getView(this.tabsCollection),
errorHolder = view.$('.wrapper-msg');
view.showErrorMessage('<p>error message is here</p>');
expect(errorHolder).not.toHaveClass('is-hidden');
expect(errorHolder.find('.copy')).toContainHtml('<p>error message is here</p>');
view.hideErrorMessage();
expect(errorHolder).toHaveClass('is-hidden');
expect(errorHolder.find('.copy')).toBeEmpty();
});
it('should hide error messages before rendering', function () {
var view = getView(this.tabsCollection),
errorHolder = view.$('.wrapper-msg');
view.showErrorMessage('<p>error message is here</p>');
view.render();
expect(errorHolder).toHaveClass('is-hidden');
expect(errorHolder.find('.copy')).toBeEmpty();
});
});
});

View File

@@ -0,0 +1,67 @@
define([
'jquery', 'underscore', 'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers',
'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs',
'js/edxnotes/views/tabs/course_structure', 'js/spec/edxnotes/custom_matchers',
'jasmine-jquery'
], function(
$, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, CourseStructureView,
customMatchers
) {
'use strict';
describe('EdxNotes CourseStructureView', function() {
var notes = Helpers.getDefaultNotes(),
getView, getText;
getText = function (selector) {
return $(selector).map(function () {
return _.trim($(this).text());
}).toArray();
};
getView = function (collection, tabsCollection, options) {
var view;
options = _.defaults(options || {}, {
el: $('.wrapper-student-notes'),
collection: collection,
tabsCollection: tabsCollection,
});
view = new CourseStructureView(options);
tabsCollection.at(0).activate();
return view;
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.collection = new NotesCollection(notes);
this.tabsCollection = new TabsCollection();
});
it('displays a tab and content with proper data and order', function () {
var view = getView(this.collection, this.tabsCollection),
chapters = getText('.course-title'),
sections = getText('.course-subtitle'),
notes = getText('.note-excerpt-p');
expect(this.tabsCollection).toHaveLength(1);
expect(this.tabsCollection.at(0).toJSON()).toEqual({
name: 'Location in Course',
identifier: 'view-course-structure',
icon: 'fa fa-list-ul',
is_active: true,
is_closable: false
});
expect(view.$('#structure-panel')).toExist();
expect(chapters).toEqual(['First Chapter', 'Second Chapter']);
expect(sections).toEqual(['First Section', 'Second Section', 'Third Section']);
expect(notes).toEqual(['Note 1', 'Note 2', 'Note 3', 'Note 4', 'Note 5']);
});
});
});

View File

@@ -0,0 +1,76 @@
define([
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/notes',
'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/recent_activity',
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function(
$, TemplateHelpers, NotesCollection, TabsCollection, RecentActivityView, customMatchers
) {
'use strict';
describe('EdxNotes RecentActivityView', function() {
var notes = [
{
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Should be listed first'
},
{
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Should be listed second'
},
{
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Should be listed third'
}
], getView;
getView = function (collection, tabsCollection, options) {
var view;
options = _.defaults(options || {}, {
el: $('.wrapper-student-notes'),
collection: collection,
tabsCollection: tabsCollection,
});
view = new RecentActivityView(options);
tabsCollection.at(0).activate();
return view;
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.collection = new NotesCollection(notes);
this.tabsCollection = new TabsCollection();
});
it('displays a tab and content with proper data and order', function () {
var view = getView(this.collection, this.tabsCollection);
expect(this.tabsCollection).toHaveLength(1);
expect(this.tabsCollection.at(0).toJSON()).toEqual({
name: 'Recent Activity',
identifier: 'view-recent-activity',
icon: 'fa fa-clock-o',
is_active: true,
is_closable: false
});
expect(view.$('#recent-panel')).toExist();
expect(view.$('.note')).toHaveLength(3);
_.each(view.$('.note'), function(element, index) {
expect($('.note-comments', element)).toContainText(notes[index].text);
expect($('.note-excerpt', element)).toContainText(notes[index].quote);
});
});
});
});

View File

@@ -0,0 +1,205 @@
define([
'jquery', 'js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers',
'logger', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results',
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function(
$, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView,
customMatchers
) {
'use strict';
describe('EdxNotes SearchResultsView', function() {
var notes = [
{
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Should be listed first'
},
{
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Should be listed second'
},
{
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Should be listed third'
}
],
responseJson = {
total: 3,
rows: notes
},
getView, submitForm;
getView = function (tabsCollection, options) {
options = _.defaults(options || {}, {
el: $('.wrapper-student-notes'),
tabsCollection: tabsCollection,
user: 'test_user',
courseId: 'course_id',
createTabOnInitialization: false
});
return new SearchResultsView(options);
};
submitForm = function (searchBox, text) {
searchBox.$('.search-notes-input').val(text);
searchBox.$('.search-notes-submit').click();
};
beforeEach(function () {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes.html');
TemplateHelpers.installTemplates([
'templates/edxnotes/note-item', 'templates/edxnotes/tab-item'
]);
this.tabsCollection = new TabsCollection();
});
it('does not create a tab and content on initialization', function () {
var view = getView(this.tabsCollection);
expect(this.tabsCollection).toHaveLength(0);
expect(view.$('#search-results-panel')).not.toExist();
});
it('displays a tab and content on search with proper data and order', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
submitForm(view.searchBox, 'second');
AjaxHelpers.respondWithJson(requests, responseJson);
expect(this.tabsCollection).toHaveLength(1);
expect(this.tabsCollection.at(0).toJSON()).toEqual({
name: 'Search Results',
identifier: 'view-search-results',
icon: 'fa fa-search',
is_active: true,
is_closable: true
});
expect(view.$('#search-results-panel')).toExist();
expect(view.$('#search-results-panel')).toBeFocused();
expect(view.$('.note')).toHaveLength(3);
view.searchResults.collection.each(function (model, index) {
expect(model.get('text')).toBe(notes[index].text);
});
});
it('displays loading indicator when search is running', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
submitForm(view.searchBox, 'test query');
expect(view.$('.ui-loading')).not.toHaveClass('is-hidden');
expect(view.$('.ui-loading')).toBeFocused();
expect(this.tabsCollection).toHaveLength(1);
expect(view.searchResults).toBeNull();
expect(view.$('.tab-panel')).not.toExist();
AjaxHelpers.respondWithJson(requests, responseJson);
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
});
it('displays no results message', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
submitForm(view.searchBox, 'some text');
AjaxHelpers.respondWithJson(requests, {
total: 0,
rows: []
});
expect(view.$('#search-results-panel')).not.toExist();
expect(view.$('#no-results-panel')).toBeFocused();
expect(view.$('#no-results-panel')).toExist();
expect(view.$('#no-results-panel')).toContainText(
'No results found for "some text".'
);
});
it('does not send an additional request on switching between tabs', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
spyOn(Logger, 'log');
submitForm(view.searchBox, 'test_query');
AjaxHelpers.respondWithJson(requests, responseJson);
expect(requests).toHaveLength(1);
this.tabsCollection.add({});
this.tabsCollection.at(1).activate();
expect(view.$('#search-results-panel')).not.toExist();
this.tabsCollection.at(0).activate();
expect(requests).toHaveLength(1);
expect(view.$('#search-results-panel')).toExist();
expect(view.$('.note')).toHaveLength(3);
});
it('can clear search results if tab is closed', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
submitForm(view.searchBox, 'test_query');
AjaxHelpers.respondWithJson(requests, responseJson);
expect(view.searchResults).toBeDefined();
this.tabsCollection.at(0).destroy();
expect(view.searchResults).toBeNull();
});
it('can correctly show/hide error messages', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this);
submitForm(view.searchBox, 'test error');
requests[0].respond(
500, {'Content-Type': 'application/json'},
JSON.stringify({
error: 'test error message'
})
);
expect(view.$('.wrapper-msg')).not.toHaveClass('is-hidden');
expect(view.$('.wrapper-msg .copy')).toContainText('test error message');
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
submitForm(view.searchBox, 'Second');
AjaxHelpers.respondWithJson(requests, responseJson);
expect(view.$('.wrapper-msg')).toHaveClass('is-hidden');
expect(view.$('.wrapper-msg .copy')).toBeEmpty();
});
it('can correctly update search results', function () {
var view = getView(this.tabsCollection),
requests = AjaxHelpers.requests(this),
newNotes = [{
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'New Note',
quote: 'New Note'
}];
submitForm(view.searchBox, 'test_query');
AjaxHelpers.respondWithJson(requests, responseJson);
expect(view.$('.note')).toHaveLength(3);
submitForm(view.searchBox, 'new_test_query');
AjaxHelpers.respondWithJson(requests, {
total: 1,
rows: newNotes
});
expect(view.$('.note').length).toHaveLength(1);
view.searchResults.collection.each(function (model, index) {
expect(model.get('text')).toBe(newNotes[index].text);
});
});
});
});

View File

@@ -0,0 +1,50 @@
define([
'jquery', 'js/common_helpers/template_helpers', 'js/edxnotes/collections/tabs',
'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) {
'use strict';
describe('EdxNotes TabsListView', function() {
beforeEach(function () {
customMatchers(this);
TemplateHelpers.installTemplate('templates/edxnotes/tab-item');
this.collection = new TabsCollection([
{identifier: 'first-item'},
{identifier: 'second-item'}
]);
this.tabsList = new TabsListView({
collection: this.collection
}).render();
});
it('has correct order and class names', function () {
var firstItem = this.tabsList.$('#first-item'),
secondItem = this.tabsList.$('#second-item');
expect(firstItem).toHaveIndex(0);
expect(firstItem).toHaveClass('is-active');
expect(secondItem).toHaveIndex(1);
});
it('can add a new tab', function () {
var firstItem = this.tabsList.$('#first-item'),
thirdItem;
this.collection.add({identifier: 'third-item'});
thirdItem = this.tabsList.$('#third-item');
expect(firstItem).toHaveClass('is-active'); // first tab is still active
expect(thirdItem).toHaveIndex(2);
expect(this.tabsList.$('.tab')).toHaveLength(3);
});
it('can remove tabs', function () {
var secondItem = this.tabsList.$('#second-item');
this.collection.at(0).destroy(); // remove first tab
expect(this.tabsList.$('.tab')).toHaveLength(1);
expect(secondItem).toHaveClass('is-active'); // second tab becomes active
this.collection.at(0).destroy();
expect(this.tabsList.$('.tab')).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,99 @@
define([
'jquery', 'annotator', 'js/common_helpers/ajax_helpers', 'js/edxnotes/views/visibility_decorator',
'js/edxnotes/views/toggle_notes_factory', 'js/spec/edxnotes/helpers',
'js/spec/edxnotes/custom_matchers', 'jasmine-jquery'
], function(
$, Annotator, AjaxHelpers, VisibilityDecorator, ToggleNotesFactory, Helpers,
customMatchers
) {
'use strict';
describe('EdxNotes ToggleNotesFactory', function() {
var params = {
endpoint: '/test_endpoint',
user: 'a user',
usageId : 'an usage',
courseId: 'a course',
token: Helpers.makeToken(),
tokenUrl: '/test_token_url'
};
beforeEach(function() {
customMatchers(this);
loadFixtures(
'js/fixtures/edxnotes/edxnotes_wrapper.html',
'js/fixtures/edxnotes/toggle_notes.html'
);
VisibilityDecorator.factory(
document.getElementById('edx-notes-wrapper-123'), params, true
);
VisibilityDecorator.factory(
document.getElementById('edx-notes-wrapper-456'), params, true
);
this.toggleNotes = ToggleNotesFactory(true, '/test_url');
this.button = $('.action-toggle-notes');
this.label = this.button.find('.utility-control-label');
this.toggleMessage = $('.action-toggle-message');
});
afterEach(function () {
VisibilityDecorator._setVisibility(null);
_.invoke(Annotator._instances, 'destroy');
$('.annotator-notice').remove();
});
it('can toggle notes', function() {
var requests = AjaxHelpers.requests(this);
expect(this.button).not.toHaveClass('is-disabled');
expect(this.label).toContainText('Hide notes');
expect(this.button).toHaveClass('is-active');
expect(this.button).toHaveAttr('aria-pressed', 'true');
expect(this.toggleMessage).not.toHaveClass('is-fleeting');
expect(this.toggleMessage).toContainText('Hiding notes');
this.button.click();
expect(this.label).toContainText('Show notes');
expect(this.button).not.toHaveClass('is-active');
expect(this.button).toHaveAttr('aria-pressed', 'false');
expect(this.toggleMessage).toHaveClass('is-fleeting');
expect(this.toggleMessage).toContainText('Hiding notes');
expect(Annotator._instances).toHaveLength(0);
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
'visibility': false
});
AjaxHelpers.respondWithJson(requests, {});
this.button.click();
expect(this.label).toContainText('Hide notes');
expect(this.button).toHaveClass('is-active');
expect(this.button).toHaveAttr('aria-pressed', 'true');
expect(this.toggleMessage).toHaveClass('is-fleeting');
expect(this.toggleMessage).toContainText('Showing notes');
expect(Annotator._instances).toHaveLength(2);
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
'visibility': true
});
AjaxHelpers.respondWithJson(requests, {});
});
it('can handle errors', function() {
var requests = AjaxHelpers.requests(this),
errorContainer = $('.annotator-notice');
this.button.click();
AjaxHelpers.respondWithError(requests);
expect(errorContainer).toContainText(
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."
);
expect(errorContainer).toBeVisible();
expect(errorContainer).toHaveClass('annotator-notice-show');
expect(errorContainer).toHaveClass('annotator-notice-error');
this.button.click();
AjaxHelpers.respondWithJson(requests, {});
expect(errorContainer).not.toHaveClass('annotator-notice-show');
});
});
});

View File

@@ -0,0 +1,56 @@
define([
'annotator', 'js/edxnotes/views/visibility_decorator',
'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers'
], function(Annotator, VisibilityDecorator, Helpers, customMatchers) {
'use strict';
describe('EdxNotes VisibilityDecorator', function() {
var params = {
endpoint: '/test_endpoint',
user: 'a user',
usageId : 'an usage',
courseId: 'a course',
token: Helpers.makeToken(),
tokenUrl: '/test_token_url'
};
beforeEach(function() {
customMatchers(this);
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
this.wrapper = document.getElementById('edx-notes-wrapper-123');
});
afterEach(function () {
VisibilityDecorator._setVisibility(null);
_.invoke(Annotator._instances, 'destroy');
});
it('can initialize Notes if it visibility equals True', function() {
var note = VisibilityDecorator.factory(this.wrapper, params, true);
expect(note).toEqual(jasmine.any(Annotator));
});
it('does not initialize Notes if it visibility equals False', function() {
var note = VisibilityDecorator.factory(this.wrapper, params, false);
expect(note).toBeNull();
});
it('can disable all notes', function() {
VisibilityDecorator.factory(this.wrapper, params, true);
VisibilityDecorator.factory(document.getElementById('edx-notes-wrapper-456'), params, true);
VisibilityDecorator.disableNotes();
expect(Annotator._instances).toHaveLength(0);
});
it('can enable the note', function() {
var secondWrapper = document.getElementById('edx-notes-wrapper-456');
VisibilityDecorator.factory(this.wrapper, params, false);
VisibilityDecorator.factory(secondWrapper, params, false);
VisibilityDecorator.enableNote(this.wrapper);
expect(Annotator._instances).toHaveLength(1);
VisibilityDecorator.enableNote(secondWrapper);
expect(Annotator._instances).toHaveLength(2);
});
});
});

View File

@@ -1,5 +1,4 @@
(function(requirejs, define) {
// TODO: how can we share the vast majority of this config that is in common with CMS?
requirejs.config({
paths: {
@@ -54,6 +53,7 @@
'xblock/lms.runtime.v1': 'coffee/src/xblock/lms.runtime.v1',
'capa/display': 'xmodule_js/src/capa/display',
'string_utils': 'xmodule_js/common_static/js/src/string_utils',
'logger': 'xmodule_js/common_static/js/src/logger',
// Manually specify LMS files that are not converted to RequireJS
'history': 'js/vendor/history',
@@ -77,7 +77,10 @@
'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel',
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
'js/student_profile/profile': 'js/student_profile/profile'
'js/student_profile/profile': 'js/student_profile/profile',
// edxnotes
'annotator': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min'
},
shim: {
'gettext': {
@@ -211,6 +214,9 @@
'xmodule': {
exports: 'XModule'
},
'logger': {
exports: 'Logger'
},
'sinon': {
exports: 'sinon'
},
@@ -488,6 +494,11 @@
'js/verify_student/views/enrollment_confirmation_step_view'
]
},
// Student Notes
'annotator': {
exports: 'Annotator',
deps: ['jquery']
}
}
});
@@ -514,7 +525,26 @@
'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js',
'lms/include/js/spec/verify_student/webcam_photo_view_spec.js',
'lms/include/js/spec/verify_student/review_photos_step_view_spec.js',
'lms/include/js/spec/verify_student/make_payment_step_view_spec.js'
'lms/include/js/spec/verify_student/make_payment_step_view_spec.js',
'lms/include/js/spec/edxnotes/utils/logger_spec.js',
'lms/include/js/spec/edxnotes/views/notes_factory_spec.js',
'lms/include/js/spec/edxnotes/views/shim_spec.js',
'lms/include/js/spec/edxnotes/views/note_item_spec.js',
'lms/include/js/spec/edxnotes/views/notes_page_spec.js',
'lms/include/js/spec/edxnotes/views/search_box_spec.js',
'lms/include/js/spec/edxnotes/views/tabs_list_spec.js',
'lms/include/js/spec/edxnotes/views/tab_item_spec.js',
'lms/include/js/spec/edxnotes/views/tab_view_spec.js',
'lms/include/js/spec/edxnotes/views/tabs/search_results_spec.js',
'lms/include/js/spec/edxnotes/views/tabs/recent_activity_spec.js',
'lms/include/js/spec/edxnotes/views/tabs/course_structure_spec.js',
'lms/include/js/spec/edxnotes/views/visibility_decorator_spec.js',
'lms/include/js/spec/edxnotes/views/toggle_notes_factory_spec.js',
'lms/include/js/spec/edxnotes/models/tab_spec.js',
'lms/include/js/spec/edxnotes/models/note_spec.js',
'lms/include/js/spec/edxnotes/plugins/events_spec.js',
'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
'lms/include/js/spec/edxnotes/collections/notes_spec.js'
]);
}).call(this, requirejs, define);

View File

@@ -30,7 +30,7 @@ prepend_path: lms/static
lib_paths:
- xmodule_js/common_static/js/test/i18n.js
- xmodule_js/common_static/coffee/src/ajax_prefix.js
- xmodule_js/common_static/coffee/src/logger.js
- xmodule_js/common_static/js/src/logger.js
- xmodule_js/common_static/js/vendor/jasmine-jquery.js
- xmodule_js/common_static/js/vendor/jasmine-imagediff.js
- xmodule_js/common_static/js/vendor/require.js
@@ -55,6 +55,9 @@ lib_paths:
- xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js
- xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js
- xmodule_js/common_static/js/test/i18n.js
- xmodule_js/common_static/js/vendor/date.js
# Paths to source JavaScript files
src_paths:
@@ -77,10 +80,12 @@ spec_paths:
fixture_paths:
- templates/instructor/instructor_dashboard_2
- templates/dashboard
- templates/edxnotes
- templates/student_account
- templates/student_profile
- templates/verify_student
- templates/file-upload.underscore
- js/fixtures/edxnotes
requirejs:
paths:

View File

@@ -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

View File

@@ -1,8 +1,29 @@
;(function (require, define, _) {
;(function (require, define) {
var paths = {}, config;
// URI, tinymce, or jquery.tinymce may already have been loaded before the OVA templates and we do not want to load
// them a second time. Check if it is the case and use the global var in requireJS config.
// jquery, underscore, gettext, URI, tinymce, or jquery.tinymce may already
// have been loaded and we do not want to load them a second time. Check if
// it is the case and use the global var instead.
if (window.jQuery) {
define("jquery", [], function() {return window.jQuery;});
} else {
paths.jquery = "js/vendor/jquery.min";
}
if (window._) {
define("underscore", [], function() {return window._;});
} else {
paths.jquery = "js/vendor/underscore-min";
}
if (window.gettext) {
define("gettext", [], function() {return window.gettext;});
} else {
paths.gettext = "/i18n";
}
if (window.Logger) {
define("logger", [], function() {return window.Logger;});
} else {
paths.logger = "js/src/logger";
}
if (window.URI) {
define("URI", [], function() {return window.URI;});
} else {
@@ -20,10 +41,14 @@
}
config = {
// NOTE: baseUrl has been previously set in lms/templates/main.html
// NOTE: baseUrl has been previously set in lms/static/templates/main.html
waitSeconds: 60,
paths: {
// Files only needed for OVA
"annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min",
"date": "js/vendor/date",
"backbone": "js/vendor/backbone-min",
"underscore.string": "js/vendor/underscore.string.min",
// Files needed by OVA
"annotator": "js/vendor/ova/annotator-full",
"annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth",
"video.dev": "js/vendor/ova/video.dev",
@@ -42,10 +67,30 @@
"ova": 'js/vendor/ova/ova',
"catch": 'js/vendor/ova/catch/js/catch',
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
// end of files only needed for OVA
// end of files needed by OVA
},
shim: {
// The following are all needed for OVA
"annotator_1.2.9": {
deps: ["jquery"],
exports: "Annotator"
},
"date": {
exports: "Date"
},
"jquery": {
exports: "$"
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"logger": {
exports: "Logger"
},
// Needed by OVA
"video.dev": {
exports:"videojs"
},
@@ -74,7 +119,7 @@
deps: ["annotator"]
},
"diacritic-annotator": {
deps: ["annotator"]
deps: ["annotator"]
},
"flagging-annotator": {
deps: ["annotator"]
@@ -99,9 +144,19 @@
"URI"
]
},
// End of OVA
// End of needed by OVA
},
map: {
"js/edxnotes/*": {
"annotator": "annotator_1.2.9"
}
}
};
_.extend(config.paths, paths);
for (var key in paths) {
if ({}.hasOwnProperty.call(paths, key)) {
config.paths[key] = paths[key];
}
}
require.config(config);
}).call(this, require || RequireJS.require, define || RequireJS.define, _);
}).call(this, require || RequireJS.require, define || RequireJS.define);

File diff suppressed because one or more lines are too long

View File

@@ -261,3 +261,21 @@
@-webkit-keyframes fade-in-animation{ @include fade-in-keyframes; }
@-moz-keyframes fade-in-animation{ @include fade-in-keyframes; }
@keyframes fade-in-animation{ @include fade-in-keyframes; }
// +utility animations
// --------------------
// pulse - double + fade out
@include keyframes(pulse-out) {
0%, 100% {
opacity: 0;
}
25%, 75% {
opacity: 1.0;
}
100% {
opacity: 0;
}
}

View File

@@ -164,3 +164,42 @@
white-space: nowrap;
text-overflow: ellipsis;
}
// border control
%no-border-top {
border-top: none;
}
%no-border-bottom {
border-bottom: none;
}
%no-border-left {
border-left: none;
}
%no-border-right {
border-right: none;
}
// outline
%no-outline {
outline: none;
}
// shame-based mixins to centrally override poor styling
%shame-link-base {
color: $link-color;
&:hover, &:focus {
color: saturate($link-color, 50%);
}
}
%shame-link-text {
@extend %shame-link-base;
&:hover, &:focus {
text-decoration: underline !important;
}
}

View File

@@ -329,6 +329,8 @@ $header-graphic-sub-color: $m-gray-d2;
$error-color: $error-red;
$warning-color: $m-pink;
$confirm-color: $m-green;
$active-color: $blue;
$highlight-color: rgb(255,255,0);
// Notifications
$notify-banner-bg-1: rgb(56,56,56);
@@ -444,3 +446,13 @@ $blue1: #4A90E2;
$blue2: #00A1E5;
$green1: #61A12E;
$red1: #D0021B;
// +case: search/result highlight
// --------------------
$result-highlight-color-base: rgba($highlight-color, 0.25);
// +feature: student notes
// --------------------
$student-notes-highlight-color-base: saturate($yellow, 65%);
$student-notes-highlight-color: tint($student-notes-highlight-color-base, 50%);
$student-notes-highlight-color-focus: $student-notes-highlight-color-base;

View File

@@ -27,36 +27,37 @@
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/navigation'; // all archetypes of navigation
// Course base / layout styles
// course - base
@import 'course/layout/courseware_header';
@import 'course/layout/footer';
@import 'course/base/mixins';
@import 'course/base/base';
@import 'course/base/extends';
@import 'xmodule/modules/css/module-styles.scss';
// courseware
@import 'course/courseware/courseware';
@import 'course/courseware/sidebar';
@import 'course/courseware/amplifier';
@import 'course/layout/calculator';
@import 'course/layout/timer';
@import 'course/layout/chat';
// course-specific courseware (all styles in these files should be gated by a
// course-specific class). This should be replaced with a better way of
// providing course-specific styling.
// course - modules
@import 'course/modules/student-notes'; // student notes
@import 'course/modules/calculator'; // calculator utility
@import 'course/modules/timer'; // timer
@import 'course/modules/chat'; // chat utility
// course - specific courses
@import "course/courseware/courses/_cs188.scss";
// wiki
// course - wiki
@import "course/wiki/basic-html";
@import "course/wiki/sidebar";
@import "course/wiki/create";
@import "course/wiki/wiki";
@import "course/wiki/table";
// pages
// course - views
@import "course/info";
@import "course/syllabus"; // TODO arjun replace w/ custom tabs, see courseware/courses.py
@import "course/textbook";
@@ -66,12 +67,13 @@
@import "course/staff_grading";
@import "course/rubric";
@import "course/open_ended_grading";
@import "course/student-notes";
// instructor
// course - instructor-only views
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
@import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss";
// discussion
// course - discussion
@import "course/discussion/form-wmd-toolbar";

View File

@@ -27,36 +27,37 @@
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/navigation'; // all archetypes of navigation
// Course base / layout styles
// course - base
@import 'course/layout/courseware_header';
@import 'course/layout/footer';
@import 'course/base/mixins';
@import 'course/base/base';
@import 'course/base/extends';
@import 'xmodule/modules/css/module-styles.scss';
// courseware
@import 'course/courseware/courseware';
@import 'course/courseware/sidebar';
@import 'course/courseware/amplifier';
@import 'course/layout/calculator';
@import 'course/layout/timer';
@import 'course/layout/chat';
// course-specific courseware (all styles in these files should be gated by a
// course-specific class). This should be replaced with a better way of
// providing course-specific styling.
// course - modules
@import 'course/modules/student-notes'; // student notes
@import 'course/modules/calculator'; // calculator utility
@import 'course/modules/timer'; // timer
@import 'course/modules/chat'; // chat utility
// course - specific courses
@import "course/courseware/courses/_cs188.scss";
// wiki
// course - wiki
@import "course/wiki/basic-html";
@import "course/wiki/sidebar";
@import "course/wiki/create";
@import "course/wiki/wiki";
@import "course/wiki/table";
// pages
// course - views
@import "course/info";
@import "course/syllabus"; // TODO arjun replace w/ custom tabs, see courseware/courses.py
@import "course/textbook";
@@ -66,12 +67,13 @@
@import "course/staff_grading";
@import "course/rubric";
@import "course/open_ended_grading";
@import "course/student-notes";
// instructor
// course - instructor-only views
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
@import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss";
// discussion
// course - discussion
@import "course/discussion/form-wmd-toolbar";

View File

@@ -0,0 +1,398 @@
// LMS -- views -- student notes
// ====================
// in this document:
// --------------------
// +notes
// +base
// ++header +and search
// +local variables/utilities
// +individual group of notes
// +tabbed views
// +search - no results
// +search - error
// +case - no notes made
// +notes:
// --------------------
// * this Sass partial contains all of the styling needed for the student notes listing view.
// * for other notes styling referenced here, see the Sass partial contains the in-line student notes UI.
// +local variables/utilities:
// --------------------
$divider-visual-primary: ($baseline/5) solid $gray-l4;
$divider-visual-secondary: ($baseline/10) solid $gray-l4;
$divider-visual-tertiary: ($baseline/20) solid $gray-l4;
%notes-tab-control {
@include transition(none);
@extend %shame-link-base;
display: inline-block;
vertical-align: middle;
border-bottom: ($baseline/5) solid $transparent;
}
.view-student-notes {
// +base:
// --------------------
.wrapper-student-notes {
@include clearfix();
padding-bottom: $baseline;
.student-notes {
@include clearfix();
@extend .content; // needed extend carried over from course handouts UI, but should be cleaned up
width: 100%;
}
}
// +header +and search:
// --------------------
.title-search-container {
@include clearfix();
margin-bottom: $baseline;
.wrapper-title {
@include float(left);
width: flex-grid(7,12);
.page-title {
@extend %t-title4;
@extend %t-weight1;
margin-bottom: 0;
.page-subtitle {
@extend %t-title7;
@extend %t-weight2;
display: block;
margin-top: ($baseline/4);
color: $gray-l1;
letter-spacing: 0;
}
}
}
.wrapper-notes-search {
@include float(right);
width: flex-grid(5,12);
@include text-align(right);
}
.search-notes-input, .search-notes-submit {
display: inline-block;
vertical-align: middle;
}
.search-notes-input {
@extend %t-demi-strong;
position: relative;
@include right(-6px); // manually positioning input right next to submit
width: 55%;
padding: ($baseline/2) ($baseline*0.75);
color: $gray-d3;
}
.search-notes-submit {
@extend %btn-inherited-primary;
@extend %t-action2;
padding: 8px $baseline 9px $baseline; // manually syncing up height with search input
}
}
// +individual group of notes
// --------------------
.note-group {
border-top: $divider-visual-primary;
margin: 0;
padding-top: ($baseline*1.5);
// course structure labels
.course-title {
@extend %t-title6;
@extend %t-weight4;
margin: 0 0 ($baseline/2) 0;
color: $gray-d3;
}
.course-subtitle {
@extend %t-title7;
@extend %t-weight4;
margin: 0 0 ($baseline/4) 0;
border-bottom: $divider-visual-tertiary;
padding-bottom: ($baseline/2);
color: $gray-d3;
}
// individual note
.note {
@include clearfix();
margin: ($baseline*1.5) 0;
.wrapper-note-excerpts {
@include transition(box-shadow $tmg-avg ease-in-out 0, border-color $tmg-avg ease-in-out 0);
display: inline-block;
width: flex-grid(9, 12);
border: 1px solid $gray-l5;
border-radius: ($baseline/10);
// note - highlighted content
.note-excerpt {
@include transition(background-color $tmg-avg ease-in-out 0);
padding: $baseline;
background: $student-notes-highlight-color;
.note-excerpt-p,
.note-excerpt-ul,
.note-excerpt-ol {
@extend %t-copy-base;
}
}
.note-excerpt-more-link {
@extend %t-copy-sub1;
@extend %t-weight2;
@extend %shame-link-text;
display: inline;
@include margin-left($baseline/4);
}
// note - comment made on highlighted content
.note-comments {
@extend %ui-no-list;
border-top: ($baseline/5) solid $student-notes-highlight-color-focus;
.note-comment {
@include transition(color $tmg-avg ease-in-out 0);
padding: ($baseline*0.75) $baseline;
color: $gray;
.note-comment-title {
@extend %t-title8;
letter-spacing: ($baseline/20);
margin: 0 0 ($baseline/4) 0;
color: $gray-l2;
}
.note-comment-p,
.note-comment-ul,
.note-comment-ol {
@extend %t-copy-sub1;
@extend %t-weight2;
padding: 0;
margin: 0;
background: transparent;
}
.note-comment-ul,
.note-comment-ol {
padding: auto;
margin: auto;
}
// CASE: when a comment has a term that matches a notes search query
.note-highlight {
background-color: $result-highlight-color-base;
}
}
}
}
// note reference
.reference {
@extend %t-copy-sub1;
display: inline-block;
width: flex-grid(3, 12);
vertical-align: top;
.wrapper-reference-content {
padding: 0 $baseline;
color: $gray-l2;
.reference-title {
@extend %t-title8;
@extend %t-weight3;
margin-top: $baseline;
text-transform: uppercase;
letter-spacing: ($baseline/20);
color: $gray-l2;
// CASE: first reference title of a note
&:first-child {
margin-top: 0;
}
}
.reference-meta {
@extend %t-weight2;
color: $m-gray-d2;
}
// needed for poor base LMS styling scope
a.reference-meta {
@extend %shame-link-text;
}
}
}
// STATE: hover/focus
&:hover, &:focus {
.wrapper-note-excerpts {
box-shadow: 0 2px 0 1px $shadow-l2;
border-color: $gray-l4;
}
.note-excerpt {
background: $student-notes-highlight-color-focus;
}
.note-comment {
color: $gray-d2;
}
}
}
}
// +tabbed views
// --------------------
.wrapper-tabs {
.tab-panel, .inline-error, .ui-loading {
@extend %no-outline;
}
.tab-panel.note-group {
padding-top: 0;
}
.inline-error {
margin: ($baseline/2) 0;
border-bottom: 1px solid $red;
padding: 0 0 ($baseline/2) 0;
color: $red;
}
.tab-list {
@include clearfix();
position: relative;
top: ($baseline/5);
.tabs-label, .tabs {
display: inline-block;
vertical-align: middle;
}
.tabs-label {
@extend %hd-lv5;
margin-bottom: 0;
padding: ($baseline*0.75) 0;
@include padding-right($baseline);
color: $gray-l2;
font-weight: $font-semibold !important; // needed for poor base LMS styling scope
}
.tabs {
@include clearfix();
@extend %ui-no-list;
position: relative;
bottom: -($baseline/4);
}
.tab {
position: relative;
display: inline;
.tab-label {
@extend %notes-tab-control;
padding: ($baseline/2) ($baseline*0.75);
text-align: center;
.icon {
@include margin-right($baseline/10);
}
}
// STATE: active/current tab being viewed
&.is-active {
.tab-label {
border-bottom-color: $gray-d3;
color: $gray-d3;
}
// CASE: tab-label can be closed
.action-close {
border-bottom: ($baseline/5) solid $gray-d3;
}
}
// CASE: tab-label can be closed
.action-close {
@extend %notes-tab-control;
position: relative;
@include left(-($baseline*0.75));
padding: ($baseline/2);
}
}
}
}
// +search - no results
// --------------------
// NOTE: not a lot of elements/classes to reference in this DOM
#no-results-panel {
p {
@extend %t-copy-lead1;
margin: ($baseline*1.5) 0;
}
}
// +search - error
// --------------------
.wrapper-msg {
margin-bottom: $baseline;
}
// +case - no notes made
// --------------------
.placeholder {
background: $gray-l5;
border-top: ($baseline/4) solid $active-color;
padding: ($baseline*1.5);
}
.placeholder-title {
@extend %hd-lv3;
margin-bottom: $baseline;
text-transform: none; // reset needed for poor h2 element styling
letter-spacing: 0; // reset needed for poor h2 element styling
}
.placeholder-copy {
@extend %t-copy-sub1;
ul {
@extend %ui-no-list;
li {
@extend %wipe-last-child;
display: block;
margin-bottom: ($baseline/2);
}
}
p, ul {
margin-bottom: $baseline;
}
}
.placeholder-cta-copy {
@extend %t-strong;
a {
@extend %t-strong;
}
}
}

View File

@@ -1,3 +1,6 @@
// LMS -- modules -- calculator
// ====================
div.calc-main {
bottom: -126px;
left: 0;
@@ -5,39 +8,37 @@ div.calc-main {
@include transition(bottom $tmg-avg linear 0s);
-webkit-appearance: none;
width: 100%;
z-index: 99;
&.open {
bottom: -36px;
}
a.calc {
@include text-hide();
background: url("../images/calc-icon.png") rgba(#111, .9) no-repeat center;
.calc {
@include transition(background-color $tmg-f2 ease-in-out 0s);
background: url("../images/calc-icon.png") $black-t1 no-repeat center;
border-bottom: 0;
border-radius: 3px 3px 0 0;
color: $white;
float: right;
height: 20px;
display: inline-block;
margin-right: ($baseline/2);
padding: 8px 12px;
height: $baseline;
margin-right: ($baseline*0.75);
padding: $baseline;
position: relative;
top: -45px;
width: 16px;
top: -42px;
width: ($baseline*0.75);
&:hover, &:focus {
opacity: 0.8;
background-color: $gray-d1;
}
&.closed {
background-image: url("../images/close-calc-icon.png");
background-color: $black;
top: -36px;
}
}
div#calculator_wrapper {
background: rgba(#111, .9);
background: $black;
clear: both;
max-height: 90px;
position: relative;

View File

@@ -1,5 +1,5 @@
/* Chat
-------------------------------------------------- */
// LMS -- modules -- chat
// ====================
#chat-wrapper {
position: fixed;
bottom: 0;

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,6 @@
// LMS -- modules -- student notes
// ====================
div.timer-main {
@extend %ui-depth2;
position: fixed;

View File

@@ -0,0 +1,117 @@
// LMS -- elements -- navigation
// ====================
// in this document:
// --------------------
// +notes
// +skip navigation
// +utility navigation
// +toggling utilities
// +case - calculator spacing
// +notes:
// --------------------
// this Sass partial should have its contents eventually abstracted out so that onboarding/non-coureware navigation is separate from in course-based navigation systems
// +skip navigation
// --------------------
%nav-skip {
@extend %text-sr;
}
.nav-contents, .nav-skip {
@extend %nav-skip;
}
// +utility navigation (course utiltiies)
// --------------------
.nav-utilities {
@extend %ui-depth3;
position: fixed;
right: ($baseline/4);
bottom: 0;
.wrapper-utility {
@extend %wipe-last-child;
display: inline-block;
vertical-align: bottom;
@include margin-right(6px);
}
.utility-control {
@include transition(background-color $tmg-f2 ease-in-out 0s, color $tmg-f2 ease-in-out 0s);
position: relative;
bottom: -($baseline/5);
display: inline-block;
vertical-align: middle;
padding: ($baseline/2) ($baseline*0.75) ($baseline*0.75) ($baseline*0.75);
background: $black-t1;
color: $white;
// STATE: hover/active
&:hover, &:active {
background: $gray-d1;
}
// STATE: is active/in use
&.is-active {
background: $gray-d1;
}
}
// specific reset styling for any controls that are button elements
.utility-control-button {
border: none;
box-shadow: none;
text-shadow: none;
font-size: inherit;
font-weight: inherit;
line-height: 0;
border-radius: 0;
// STATE: hover/active
&:hover, &:active, &:focus {
border: none;
box-shadow: none;
}
}
// specific utility navigation - student notes toggling
.action-toggle-notes {
@extend %no-outline;
// STATE: is active/in use
&.is-active {
color: $student-notes-highlight-color-base;
}
}
// +toggling utilities
// --------------------
.action-toggle-message {
@extend %t-title8;
@extend %t-strong;
position: absolute;
bottom: 0;
@include right($baseline*2.5);
display: inline-block;
min-width: ($baseline*5);
padding: ($baseline/2) ($baseline*0.75);
opacity: 0;
background-color: $gray-d1;
color: $white;
text-align: center;
// STATE: is fleeting/temporary
&.is-fleeting {
@include animation(pulse-out $tmg-s2 ease-in-out);
}
}
// +case - calculator spacing (needed for overriding calculator positioning)
// --------------------
&.has-utility-calculator {
@include right($baseline*2.50);
}
}

View File

@@ -120,6 +120,10 @@
border-top: 3px solid $alert-color;
}
&.error {
border-top: 3px solid $error-color;
}
&.warning {
border-top: 3px solid $warning-color;
}

View File

@@ -196,11 +196,11 @@
// typography weights
%t-weight1 {
font-weight: 300;
font-weight: $font-light;
}
%t-weight2 {
font-weight: 400;
font-weight: $font-regular;
}
%t-weight3 {
@@ -208,11 +208,11 @@
}
%t-weight4 {
font-weight: 600;
font-weight: $font-semibold;
}
%t-weight5 {
font-weight: 700;
font-weight: $font-bold;
}
// ====================