Merge pull request #7923 from edx/christina/show-notes

Tags Tab in Student Notes
This commit is contained in:
Christina Roberts
2015-05-14 12:30:38 -04:00
22 changed files with 662 additions and 96 deletions

View File

@@ -1,6 +1,6 @@
;(function (define, undefined) {
'use strict';
define(['backbone'], function (Backbone) {
define(['underscore', 'backbone'], function (_, Backbone) {
var TabModel = Backbone.Model.extend({
defaults: {
'identifier': '',

View File

@@ -3,19 +3,22 @@
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var NoteSectionView, NoteGroupView;
var GroupView, ChapterView;
NoteSectionView = Backbone.View.extend({
GroupView = Backbone.View.extend({
tagName: 'section',
className: 'note-section',
id: function () {
return 'note-section-' + _.uniqueId();
},
template: _.template('<h4 class="course-subtitle"><%- sectionName %></h4>'),
initialize: function () {
this.template = _.template(this.options.template);
this.className = this.options.className;
},
render: function () {
this.$el.prepend(this.template({
sectionName: this.options.section.display_name
displayName: this.options.displayName
}));
return this;
@@ -26,7 +29,7 @@ define([
}
});
NoteGroupView = Backbone.View.extend({
ChapterView = Backbone.View.extend({
tagName: 'section',
className: 'note-group',
id: function () {
@@ -52,7 +55,13 @@ define([
},
addChild: function (sectionInfo) {
var section = new NoteSectionView({section: sectionInfo});
var section = new GroupView(
{
displayName: sectionInfo.display_name,
template: '<h4 class="course-subtitle"><%- displayName %></h4>',
className: "note-section"
}
);
this.children.push(section);
return section;
},
@@ -65,6 +74,6 @@ define([
}
});
return NoteGroupView;
return {GroupView: GroupView, ChapterView: ChapterView};
});
}).call(this, define || RequireJS.define);

View File

@@ -13,6 +13,7 @@ define([
events: {
'click .note-excerpt-more-link': 'moreHandler',
'click .reference-unit-link': 'unitLinkHandler',
'click .reference-tags': 'tagHandler'
},
initialize: function (options) {
@@ -56,6 +57,11 @@ define([
}, this));
},
tagHandler: function (event) {
event.preventDefault();
this.options.scrollToTag(event.currentTarget.text);
},
redirectTo: function (uri) {
window.location = uri;
},

View File

@@ -7,7 +7,7 @@ define([
'js/edxnotes/plugins/caret_navigation'
], function ($, _, Annotator, NotesLogger) {
var plugins = ['Auth', 'Store', 'Scroller', 'Events', 'Accessibility', 'CaretNavigation', 'Tags'],
getOptions, setupPlugins, updateHeaders, getAnnotator;
getOptions, setupPlugins, getAnnotator;
/**
* Returns options for the annotator.

View File

@@ -3,33 +3,53 @@
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'
'js/edxnotes/views/tabs/search_results', 'js/edxnotes/views/tabs/tags'
], function (
Backbone, TabsCollection, TabsListView, RecentActivityView, CourseStructureView,
SearchResultsView
SearchResultsView, TagsView
) {
var NotesPageView = Backbone.View.extend({
initialize: function (options) {
var scrollToTag, tagsModel;
this.options = options;
this.tabsCollection = new TabsCollection();
// Must create the Tags view first to get the "scrollToTag" method.
this.tagsView = new TagsView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
});
scrollToTag = this.tagsView.scrollToTag;
// Remove the Tags model from the tabs collection because it should not appear first.
tagsModel = this.tabsCollection.shift();
this.recentActivityView = new RecentActivityView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
tabsCollection: this.tabsCollection,
scrollToTag: scrollToTag
});
this.courseStructureView = new CourseStructureView({
el: this.el,
collection: this.collection,
tabsCollection: this.tabsCollection
tabsCollection: this.tabsCollection,
scrollToTag: scrollToTag
});
// Add the Tags model after the Course Structure model.
this.tabsCollection.push(tagsModel);
this.searchResultsView = new SearchResultsView({
el: this.el,
tabsCollection: this.tabsCollection,
debug: this.options.debug,
createTabOnInitialization: false
createTabOnInitialization: false,
scrollToTag: scrollToTag
});
this.tabsView = new TabsListView({collection: this.tabsCollection});

View File

@@ -1,7 +1,7 @@
;(function (define, undefined) {
'use strict';
define(['gettext', 'underscore', 'backbone', 'js/edxnotes/utils/template'],
function (gettext, _, Backbone, templateUtils) {
define(['gettext', 'underscore', 'jquery', 'backbone', 'js/edxnotes/utils/template'],
function (gettext, _, $, Backbone, templateUtils) {
var TabItemView = Backbone.View.extend({
tagName: 'li',
className: 'tab',

View File

@@ -26,9 +26,9 @@ function (gettext, _, Backbone, NoteItemView) {
},
getNotes: function (collection) {
var container = document.createDocumentFragment(),
var container = document.createDocumentFragment(), scrollToTag = this.options.scrollToTag,
notes = _.map(collection, function (model) {
var note = new NoteItemView({model: model});
var note = new NoteItemView({model: model, scrollToTag: scrollToTag});
container.appendChild(note.render().el);
return note;
});

View File

@@ -1,8 +1,8 @@
;(function (define, undefined) {
'use strict';
define([
'underscore', 'backbone', 'js/edxnotes/models/tab'
], function (_, Backbone, TabModel) {
'jquery', 'underscore', 'backbone', 'js/edxnotes/models/tab'
], function ($, _, Backbone, TabModel) {
var TabView = Backbone.View.extend({
PanelConstructor: null,
@@ -52,6 +52,7 @@ define([
// If the view is already rendered, destroy it.
this.destroySubView();
this.renderContent().always(this.hideLoadingIndicator);
this.$('.sr-is-focusable.sr-tab-panel').focus();
return this;
},
@@ -63,7 +64,7 @@ define([
getSubView: function () {
var collection = this.getCollection();
return new this.PanelConstructor({collection: collection});
return new this.PanelConstructor({collection: collection, scrollToTag: this.options.scrollToTag});
},
destroySubView: function () {

View File

@@ -1,9 +1,9 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'gettext', 'underscore', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'js/edxnotes/views/tab_view'
], function (gettext, NoteGroupView, TabPanelView, TabView) {
], function (gettext, _, NoteGroupView, TabPanelView, TabView) {
var CourseStructureView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'structure-panel',
@@ -14,28 +14,28 @@ define([
container = document.createDocumentFragment();
_.each(courseStructure.chapters, function (chapterInfo) {
var group = this.getGroup(chapterInfo);
var chapterView = this.getChapterGroupView(chapterInfo);
_.each(chapterInfo.children, function (location) {
var sectionInfo = courseStructure.sections[location],
section;
sectionView;
if (sectionInfo) {
section = group.addChild(sectionInfo);
sectionView = chapterView.addChild(sectionInfo);
_.each(sectionInfo.children, function (location) {
var notes = courseStructure.units[location];
if (notes) {
section.addChild(this.getNotes(notes))
sectionView.addChild(this.getNotes(notes));
}
}, this);
}
}, this);
container.appendChild(group.render().el);
container.appendChild(chapterView.render().el);
}, this);
this.$el.append(container);
return this;
},
getGroup: function (chapter, section) {
var group = new NoteGroupView({
getChapterGroupView: function (chapter, section) {
var group = new NoteGroupView.ChapterView({
chapter: chapter,
section: section
});

View File

@@ -11,7 +11,7 @@ define([
return [
TabPanelView.prototype.className,
'note-group'
].join(' ')
].join(' ');
},
renderContent: function () {
this.$el.append(this.getNotes(this.collection.toArray()));

View File

@@ -1,9 +1,9 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view',
'jquery', 'underscore', 'gettext', 'js/edxnotes/views/tab_panel', 'js/edxnotes/views/tab_view',
'js/edxnotes/views/search_box'
], function (gettext, TabPanelView, TabView, SearchBoxView) {
], function ($, _, gettext, TabPanelView, TabView, SearchBoxView) {
var SearchResultsView = TabView.extend({
PanelConstructor: TabPanelView.extend({
id: 'search-results-panel',
@@ -78,7 +78,8 @@ define([
if (collection.length) {
return new this.PanelConstructor({
collection: collection,
searchQuery: this.searchResults.searchQuery
searchQuery: this.searchResults.searchQuery,
scrollToTag: this.options.scrollToTag
});
} else {
return new this.NoResultsViewConstructor({

View File

@@ -0,0 +1,137 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'js/edxnotes/views/note_group', 'js/edxnotes/views/tab_panel',
'js/edxnotes/views/tab_view'
], function (gettext, $, _, NoteGroupView, TabPanelView, TabView) {
var TagsView = TabView.extend({
scrollToTag: function(tagName) {
var titleElement, displayedTitle;
if (!this.tabModel.isActive()) {
this.tabModel.activate();
}
displayedTitle = this.contentView.titleMap[tagName.toLowerCase()];
titleElement = this.$el.find('.tags-title').filter(
function(){ return $(this).text() === displayedTitle; }
);
$('html,body').animate(
{ scrollTop: titleElement.offset().top - 10 },
'slow',
function () { titleElement.focus(); }
);
},
initialize: function (options) {
TabView.prototype.initialize.call(this, options);
_.bindAll(this, 'scrollToTag');
this.options.scrollToTag = this.scrollToTag;
},
PanelConstructor: TabPanelView.extend({
id: 'tags-panel',
title: 'Tags',
// Translators: this is a title shown before all Notes that have no associated tags. It is put within
// brackets to differentiate it from user-defined tags, but it should still be translated.
noTags: gettext('[no tags]'), // User-defined tags cannot have spaces, so no risk of a collision.
renderContent: function () {
var notesByTag = {}, noTags = this.noTags, addNoteForTag, noteList, tags, i,
sortedTagNames, container, group, noteGroup, tagTitle, titleMap;
// Iterate through all the notes and build up a dictionary structure by tag.
// Note that the collection will be in most-recently updated order already.
addNoteForTag = function (note, tag) {
noteList = notesByTag[tag.toLowerCase()];
if (noteList === undefined) {
noteList = [];
notesByTag[tag.toLowerCase()] = noteList;
}
// If a note was tagged with the same tag more than once, don't add again.
// We can assume it would be the last element of the list because we iterate through
// all tags on a given note before moving on to the text note.
if (noteList.length === 0 || noteList[noteList.length - 1] !== note) {
noteList.push(note);
}
};
this.collection.each(function(note){
tags = note.get('tags');
if (tags.length === 0) {
addNoteForTag(note, noTags);
}
else {
for (i = 0; i < tags.length; i++) {
addNoteForTag(note, tags[i]);
}
}
});
sortedTagNames = Object.keys(notesByTag).sort(function (a, b) {
// "no tags" should always appear last
if (a === noTags) {
return 1;
}
else if (b === noTags) {
return -1;
}
else if (notesByTag[a].length > notesByTag[b].length) {
return -1;
}
else if (notesByTag[a].length < notesByTag[b].length) {
return 1;
}
else {
return a.toLowerCase() <= b.toLowerCase() ? -1 : 1;
}
});
container = document.createDocumentFragment();
// Store map of titles for scrollToTag functionality.
this.titleMap = {};
titleMap = this.titleMap;
_.each(sortedTagNames, function (tagName) {
noteGroup = notesByTag[tagName];
var tagTitle = interpolate_text(
"{tagName} ({numberOfNotesWithTag})",
{tagName: tagName, numberOfNotesWithTag: noteGroup.length}
);
group = this.getGroup(tagTitle);
titleMap[tagName] = tagTitle;
group.addChild(this.getNotes(noteGroup));
container.appendChild(group.render().el);
}, this);
this.$el.append(container);
return this;
},
getGroup: function (tagName) {
var group = new NoteGroupView.GroupView({
displayName: tagName,
template: '<h3 class="tags-title sr-is-focusable" tabindex="-1"><%- displayName %></h3>',
className: "note-group"
});
this.children.push(group);
return group;
}
}),
tabInfo: {
// Translators: 'Tags' is the name of the view (noun) within the Student Notes page that shows all
// notes organized by the tags the student has associated with them (if any). When defining a
// note in the courseware, the student can choose to associate 1 or more tags with the note
// in order to group similar notes together and help with search.
name: gettext('Tags'),
identifier: 'view-tags',
icon: 'fa fa-tag'
}
});
return TagsView;
});
}).call(this, define || RequireJS.define);

View File

@@ -105,6 +105,7 @@ define(['underscore'], function(_) {
};
getDefaultNotes = function () {
// Note that the server returns notes in reverse chronological order (newest first).
return [
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
@@ -113,7 +114,8 @@ define(['underscore'], function(_) {
created: 'December 11, 2014 at 11:12AM',
updated: 'December 11, 2014 at 11:12AM',
text: 'Third added model',
quote: 'Note 4'
quote: 'Note 4',
tags: ['Pumpkin', 'pumpkin', 'yummy']
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
@@ -131,7 +133,8 @@ define(['underscore'], function(_) {
created: 'December 11, 2014 at 11:11AM',
updated: 'December 11, 2014 at 11:11AM',
text: 'Second added model',
quote: 'Note 3'
quote: 'Note 3',
tags: ['yummy']
},
{
chapter: getChapter('Second Chapter', 0, 1, [1, 'w_n', 0]),
@@ -140,7 +143,8 @@ define(['underscore'], function(_) {
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 2'
quote: 'Note 2',
tags: ['PUMPKIN', 'pie']
},
{
chapter: getChapter('First Chapter', 1, 0, [2]),
@@ -149,7 +153,8 @@ define(['underscore'], function(_) {
created: 'December 11, 2014 at 11:10AM',
updated: 'December 11, 2014 at 11:10AM',
text: 'First added model',
quote: 'Note 1'
quote: 'Note 1',
tags: ['pie', 'pumpkin']
}
];
};

View File

@@ -9,7 +9,7 @@ define([
) {
'use strict';
describe('EdxNotes NoteItemView', function() {
var getView = function (model) {
var getView = function (model, scrollToTag) {
model = new NoteModel(_.defaults(model || {}, {
id: 'id-123',
user: 'user-123',
@@ -23,7 +23,7 @@ define([
}
}));
return new NoteItemView({model: model}).render();
return new NoteItemView({model: model, scrollToTag: scrollToTag}).render();
};
beforeEach(function() {
@@ -67,7 +67,19 @@ define([
var view = getView({tags: ["First", "Second"]});
expect(view.$('.reference-title').length).toBe(3);
expect(view.$('.reference-title')[2]).toContainText('Tags:');
expect(view.$('.reference-tags').last()).toContainText('First, Second');
expect(view.$('a.reference-tags').length).toBe(2);
expect(view.$('a.reference-tags')[0]).toContainText('First');
expect(view.$('a.reference-tags')[1]).toContainText('Second');
});
it('should handle a click event on the tag', function() {
var scrollToTagSpy = {
scrollToTag: function (tagName){}
};
spyOn(scrollToTagSpy, 'scrollToTag');
var view = getView({tags: ["only"]}, scrollToTagSpy.scrollToTag);
view.$('a.reference-tags').click();
expect(scrollToTagSpy.scrollToTag).toHaveBeenCalledWith("only");
});
it('should log the edx.student_notes.used_unit_link event properly', function () {

View File

@@ -0,0 +1,80 @@
define([
'jquery', 'underscore', 'js/common_helpers/template_helpers', 'js/spec/edxnotes/helpers',
'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs',
'js/edxnotes/views/tabs/tags', 'js/spec/edxnotes/custom_matchers',
'jasmine-jquery'
], function(
$, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, TagsView,
customMatchers
) {
'use strict';
describe('EdxNotes TagsView', function() {
var notes = Helpers.getDefaultNotes(),
getView, getText, getNoteText;
getText = function (selector) {
return $(selector).map(function () { return _.trim($(this).text()); }).toArray();
};
getNoteText = function (groupIndex) {
return $($('.note-group')[groupIndex]).find('.note-excerpt-p').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 TagsView(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 properly ordered by tag', function () {
var view = getView(this.collection, this.tabsCollection),
tags = getText('.tags-title'),
pumpkinNotes = getNoteText(0),
pieNotes = getNoteText(1),
yummyNotes = getNoteText(2),
noTagsNotes = getNoteText(3);
expect(this.tabsCollection).toHaveLength(1);
expect(this.tabsCollection.at(0).toJSON()).toEqual({
name: 'Tags',
identifier: 'view-tags',
icon: 'fa fa-tag',
is_active: true,
is_closable: false
});
expect(view.$('#tags-panel')).toExist();
// Pumpkin notes has the greatest number of notes, and therefore should come first.
// Yummy and pie notes have the same number of notes. They should be sorted alphabetically.
// "no tags" should always be last.
expect(tags).toEqual(['pumpkin (3)', 'pie (2)', 'yummy (2)', '[no tags] (1)']);
expect(pumpkinNotes).toEqual(['Note 4', 'Note 2', 'Note 1']);
expect(yummyNotes).toEqual(['Note 4', 'Note 3']);
expect(pieNotes).toEqual(['Note 2', 'Note 1']);
expect(noTagsNotes).toEqual(['Note 5']);
});
});
});

View File

@@ -616,6 +616,7 @@
'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/tabs/tags_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',

View File

@@ -108,7 +108,7 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
padding-top: ($baseline*1.5);
// course structure labels
.course-title {
.course-title, .tags-title {
@extend %t-title6;
@extend %t-weight4;
margin: 0 0 ($baseline/2) 0;
@@ -124,6 +124,11 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
color: $gray-d3;
}
.tags-title {
border-bottom: $divider-visual-tertiary;
padding-bottom: ($baseline/2);
}
// individual note
.note {
@include clearfix();
@@ -228,10 +233,22 @@ $divider-visual-tertiary: ($baseline/20) solid $gray-l4;
color: $m-gray-d2;
}
// Put commas between tags.
a.reference-meta.reference-tags:after {
content: ",";
color: $m-gray-d2;
}
// But not after the last tag.
a.reference-meta.reference-tags:last-child:after {
content: "";
}
// needed for poor base LMS styling scope
a.reference-meta {
@extend %shame-link-text;
}
}
}

View File

@@ -56,6 +56,7 @@
</span>
<span class="copy">${_("Loading")}</span>
</div>
<div class="sr-is-focusable sr-tab-panel" tabindex="-1"></div>
% else:
<section class="placeholder is-empty">

View File

@@ -16,28 +16,30 @@
<% if (text) { %>
<ol class="note-comments" role="region" aria-label="<%- gettext('Note') %>">
<li class="note-comment">
<h3 class="note-comment-title"><%- gettext("You commented...") %></h3>
<p class="note-comment-title"><%- gettext("You commented...") %></p>
<p class="note-comment-p"><%= text %></p>
</li>
</ol>
<% } %>
</div>
<footer class="reference" role="complementary" aria-label="<%- gettext('References') %>">
<footer class="reference">
<div class="wrapper-reference-content">
<h3 class="reference-title"><%- gettext("Noted in:") %></h3>
<p class="reference-title"><%- gettext("Noted in:") %></p>
<% if (unit.url) { %>
<a class="reference-meta reference-unit-link" href="<%= unit.url %>#<%= id %>"><%- unit.display_name %></a>
<% } else { %>
<span class="reference-meta"><%- unit.display_name %></span>
<% } %>
<h3 class="reference-title"><%- gettext("Last Edited:") %></h3>
<p class="reference-title"><%- gettext("Last Edited:") %></p>
<span class="reference-meta reference-updated-date"><%- updated %></span>
<% if (tags.length > 0) { %>
<h3 class="reference-title"><%- gettext("Tags:") %></h3>
<span class="reference-meta reference-tags"><%- tags.join(", ") %></span>
<p class="reference-title"><%- gettext("Tags:") %></p>
<% for (var i = 0; i < tags.length; i++) { %>
<a class="reference-meta reference-tags" href="#"><%- tags[i] %></a>
<% } %>
<% } %>
</div>
</footer>

View File

@@ -1,7 +1,7 @@
<% var hasIcon = icon ? 1 : 0; %>
<a class="tab-label <% if (hasIcon) { print('has-icon') } %>" href="#">
<% if (hasIcon) { %><i class="icon <%= icon %>"></i> <% } %><%- gettext(name) %>
<% if (hasIcon) { %><i class="icon <%= icon %>" aria-hidden="true"></i> <% } %><%- gettext(name) %>
</a>
<% if (is_closable) { %>