From db62db295c814a93d4b1f4a9fccdfff4eddced8f Mon Sep 17 00:00:00 2001
From: Andy Armstrong <andya@edx.org>
Date: Fri, 25 Mar 2016 16:44:33 -0400
Subject: [PATCH] Upgrade Underscore.string

FEDX-117
---
 cms/static/cms/js/require-config.js           |   2 +-
 cms/static/coffee/spec/main.coffee            |   2 +-
 cms/static/coffee/spec/main_squire.coffee     |   2 +-
 .../coffee/spec/views/textbook_spec.coffee    |   5 -
 .../js/certificates/models/certificate.js     | 172 +++++++++---------
 .../js/certificates/models/signatory.js       |  12 +-
 cms/static/js/models/group_configuration.js   |  19 +-
 cms/static/js/views/edit_chapter.js           | 155 ++++++++--------
 cms/static/js/views/experiment_group_edit.js  |   5 +-
 cms/static/js_test.yml                        |   2 +-
 cms/static/js_test_squire.yml                 |   2 +-
 cms/templates/textbooks.html                  |   2 +-
 .../static/common/js/spec/main_requirejs.js   |   2 +-
 .../static/js/vendor/underscore.string.min.js |   1 -
 common/static/js_test.yml                     |   2 +-
 common/static/js_test_requirejs.yml           |   4 +-
 lms/static/js/spec/main.js                    |   2 +-
 lms/static/js_test.yml                        |   2 +-
 lms/static/lms/js/require-config.js           |  12 +-
 package.json                                  |   3 +-
 pavelib/assets.py                             |   3 +-
 21 files changed, 203 insertions(+), 208 deletions(-)
 delete mode 100644 common/static/js/vendor/underscore.string.min.js

diff --git a/cms/static/cms/js/require-config.js b/cms/static/cms/js/require-config.js
index a91819ace72..18c76dd2a7f 100644
--- a/cms/static/cms/js/require-config.js
+++ b/cms/static/cms/js/require-config.js
@@ -51,7 +51,7 @@
             "moment-with-locales": "js/vendor/moment-with-locales.min",
             "text": 'js/vendor/requirejs/text',
             "underscore": "common/js/vendor/underscore",
-            "underscore.string": "js/vendor/underscore.string.min",
+            "underscore.string": "common/js/vendor/underscore.string",
             "backbone": "js/vendor/backbone-min",
             "backbone-relational" : "js/vendor/backbone-relational.min",
             "backbone.associations": "js/vendor/backbone-associations-min",
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index 638bf9a51d9..84621b8eb3b 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -27,7 +27,7 @@ requirejs.config({
         "moment-with-locales": "xmodule_js/common_static/js/vendor/moment-with-locales.min",
         "text": "xmodule_js/common_static/js/vendor/requirejs/text",
         "underscore": "xmodule_js/common_static/common/js/vendor/underscore",
-        "underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
+        "underscore.string": "xmodule_js/common_static/common/js/vendor/underscore.string",
         "backbone": "xmodule_js/common_static/js/vendor/backbone-min",
         "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
         "backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min",
diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee
index a46474744e8..a562cd98e35 100644
--- a/cms/static/coffee/spec/main_squire.coffee
+++ b/cms/static/coffee/spec/main_squire.coffee
@@ -23,7 +23,7 @@ requirejs.config({
         "date": "xmodule_js/common_static/js/vendor/date",
         "text": "xmodule_js/common_static/js/vendor/requirejs/text",
         "underscore": "xmodule_js/common_static/common/js/vendor/underscore",
-        "underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
+        "underscore.string": "xmodule_js/common_static/common/js/vendor/underscore.string",
         "backbone": "xmodule_js/common_static/js/vendor/backbone-min",
         "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
         "backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min",
diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee
index 270ad69d588..0a326b29899 100644
--- a/cms/static/coffee/spec/views/textbook_spec.coffee
+++ b/cms/static/coffee/spec/views/textbook_spec.coffee
@@ -104,11 +104,9 @@ define ["js/models/textbook", "js/models/chapter", "js/collections/chapter", "js
     describe "EditTextbook", ->
         describe "Basic", ->
             tpl = readFixtures('edit-textbook.underscore')
-            chapterTpl = readFixtures('edit-chapter.underscore')
 
             beforeEach ->
                 setFixtures($("<script>", {id: "edit-textbook-tpl", type: "text/template"}).text(tpl))
-                appendSetFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(chapterTpl))
                 appendSetFixtures(sandbox({id: "page-notification"}))
                 appendSetFixtures(sandbox({id: "page-prompt"}))
                 @model = new Textbook({name: "Life Sciences", editing: true})
@@ -298,11 +296,8 @@ define ["js/models/textbook", "js/models/chapter", "js/collections/chapter", "js
 
 
     describe "EditChapter", ->
-        tpl = readFixtures("edit-chapter.underscore")
-
         beforeEach ->
             modal_helpers.installModalTemplates()
-            appendSetFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(tpl))
             @model = new Chapter
                 name: "Chapter 1"
                 asset_path: "/ch1.pdf"
diff --git a/cms/static/js/certificates/models/certificate.js b/cms/static/js/certificates/models/certificate.js
index ec6dfff1fa6..94231d4a61b 100644
--- a/cms/static/js/certificates/models/certificate.js
+++ b/cms/static/js/certificates/models/certificate.js
@@ -1,100 +1,98 @@
 // Backbone.js Application Model: Certificate
 
-define([ // jshint ignore:line
-    'underscore',
-    'underscore.string',
-    'backbone',
-    'backbone-relational',
-    'backbone.associations',
-    'gettext',
-    'coffee/src/main',
-    'js/certificates/models/signatory',
-    'js/certificates/collections/signatories'
-],
-function (_, str, Backbone, BackboneRelational, BackboneAssociations, gettext, CoffeeSrcMain,
-          SignatoryModel, SignatoryCollection) {
-    'use strict';
-    _.str = str;
-    var Certificate = Backbone.RelationalModel.extend({
-        idAttribute: "id",
-        defaults: {
-            // Metadata fields currently displayed in web forms
-            course_title: '',
+define([
+        'underscore',
+        'backbone',
+        'backbone-relational',
+        'backbone.associations',
+        'gettext',
+        'coffee/src/main',
+        'js/certificates/models/signatory',
+        'js/certificates/collections/signatories'
+    ],
+    function(_, Backbone, BackboneRelational, BackboneAssociations, gettext, CoffeeSrcMain,
+             SignatoryModel, SignatoryCollection) {
+        'use strict';
+        var Certificate = Backbone.RelationalModel.extend({
+            idAttribute: 'id',
+            defaults: {
+                // Metadata fields currently displayed in web forms
+                course_title: '',
 
-            // Metadata fields not currently displayed in web forms
-            name: 'Name of the certificate',
-            description: 'Description of the certificate',
+                // Metadata fields not currently displayed in web forms
+                name: 'Name of the certificate',
+                description: 'Description of the certificate',
 
-            // Internal-use only, not displayed in web forms
-            version: 1,
-            is_active: false
-        },
+                // Internal-use only, not displayed in web forms
+                version: 1,
+                is_active: false
+            },
 
-        // Certificate child collection/model mappings (backbone-relational)
-        relations: [{
-            type: Backbone.HasMany,
-            key: 'signatories',
-            relatedModel: SignatoryModel,
-            collectionType: SignatoryCollection,
-            reverseRelation: {
-                key: 'certificate',
-                includeInJSON: "id"
-            }
-        }],
+            // Certificate child collection/model mappings (backbone-relational)
+            relations: [{
+                type: Backbone.HasMany,
+                key: 'signatories',
+                relatedModel: SignatoryModel,
+                collectionType: SignatoryCollection,
+                reverseRelation: {
+                    key: 'certificate',
+                    includeInJSON: 'id'
+                }
+            }],
 
-        initialize: function(attributes, options) {
-            // Set up the initial state of the attributes set for this model instance
-            this.canBeEmpty = options && options.canBeEmpty;
-            if(options.add) {
-                // Ensure at least one child Signatory model is defined for any new Certificate model
-                attributes.signatories = new SignatoryModel({certificate: this});
-            }
-            this.setOriginalAttributes();
-            return this;
-        },
+            initialize: function(attributes, options) {
+                // Set up the initial state of the attributes set for this model instance
+                this.canBeEmpty = options && options.canBeEmpty;
+                if (options.add) {
+                    // Ensure at least one child Signatory model is defined for any new Certificate model
+                    attributes.signatories = new SignatoryModel({certificate: this});
+                }
+                this.setOriginalAttributes();
+                return this;
+            },
 
-        parse: function (response) {
-            // Parse must be defined for the model, but does not need to do anything special right now
-            return response;
-        },
+            parse: function(response) {
+                // Parse must be defined for the model, but does not need to do anything special right now
+                return response;
+            },
 
-        setOriginalAttributes: function() {
-            // Remember the current state of this model (enables edit->cancel use cases)
-            this._originalAttributes = this.parse(this.toJSON());
+            setOriginalAttributes: function() {
+                // Remember the current state of this model (enables edit->cancel use cases)
+                this._originalAttributes = this.parse(this.toJSON());
 
-            this.get("signatories").each(function (modelSignatory) {
-                modelSignatory.setOriginalAttributes();
-            });
+                this.get('signatories').each(function(modelSignatory) {
+                    modelSignatory.setOriginalAttributes();
+                });
 
-            // If no url is defined for the signatories child collection we'll need to create that here as well
-            if(!this.isNew() && !this.get('signatories').url) {
-                this.get('signatories').url = this.collection.url + '/' + this.get('id') + '/signatories';
-            }
-        },
+                // If no url is defined for the signatories child collection we'll need to create that here as well
+                if (!this.isNew() && !this.get('signatories').url) {
+                    this.get('signatories').url = this.collection.url + '/' + this.get('id') + '/signatories';
+                }
+            },
 
-        validate: function(attrs) {
-            // Ensure the provided attributes set meets our expectations for format, type, etc.
-            if (!_.str.trim(attrs.name)) {
-                return {
-                    message: gettext('Certificate name is required.'),
-                    attributes: {name: true}
-                };
-            }
-            var all_signatories_valid  = _.every(attrs.signatories.models, function(signatory){
-                return signatory.isValid();
-            });
-            if (!all_signatories_valid) {
-                return {
-                    message: gettext('Signatory field(s) has invalid data.'),
-                    attributes: {signatories: attrs.signatories.models}
-                };
-            }
-        },
+            validate: function(attrs) {
+                // Ensure the provided attributes set meets our expectations for format, type, etc.
+                if (!attrs.name.trim()) {
+                    return {
+                        message: gettext('Certificate name is required.'),
+                        attributes: {name: true}
+                    };
+                }
+                var allSignatoriesValid  = _.every(attrs.signatories.models, function(signatory){
+                    return signatory.isValid();
+                });
+                if (!allSignatoriesValid) {
+                    return {
+                        message: gettext('Signatory field(s) has invalid data.'),
+                        attributes: {signatories: attrs.signatories.models}
+                    };
+                }
+            },
 
-        reset: function() {
-            // Revert the attributes of this model instance back to initial state
-            this.set(this._originalAttributes, { parse: true, validate: true });
-        }
+            reset: function() {
+                // Revert the attributes of this model instance back to initial state
+                this.set(this._originalAttributes, {parse: true, validate: true});
+            }
+        });
+        return Certificate;
     });
-    return Certificate;
-});
diff --git a/cms/static/js/certificates/models/signatory.js b/cms/static/js/certificates/models/signatory.js
index d668b6b683c..da07ed2a903 100644
--- a/cms/static/js/certificates/models/signatory.js
+++ b/cms/static/js/certificates/models/signatory.js
@@ -2,17 +2,15 @@
 
 define([ // jshint ignore:line
     'underscore',
-    'underscore.string',
     'backbone',
     'backbone-relational',
-    'gettext'
+    'underscore.string'
 ],
-function(_, str, Backbone, BackboneRelational, gettext) {
+function(_, Backbone) {
     'use strict';
-    _.str = str;
 
     var Signatory = Backbone.RelationalModel.extend({
-        idAttribute: "id",
+        idAttribute: 'id',
         defaults: {
             name: '',
             title: '',
@@ -26,7 +24,7 @@ function(_, str, Backbone, BackboneRelational, gettext) {
             return this;
         },
 
-        parse: function (response) {
+        parse: function(response) {
             // Parse must be defined for the model, but does not need to do anything special right now
             return response;
         },
@@ -38,7 +36,7 @@ function(_, str, Backbone, BackboneRelational, gettext) {
 
         reset: function() {
             // Revert the attributes of this model instance back to initial state
-            this.set(this._originalAttributes, { parse: true, validate: true });
+            this.set(this._originalAttributes, {parse: true, validate: true});
         }
     });
     return Signatory;
diff --git a/cms/static/js/models/group_configuration.js b/cms/static/js/models/group_configuration.js
index 82ac39eae2a..78a62332d9f 100644
--- a/cms/static/js/models/group_configuration.js
+++ b/cms/static/js/models/group_configuration.js
@@ -1,10 +1,9 @@
 define([
-    'backbone', 'underscore', 'underscore.string', 'gettext', 'js/models/group',
-    'js/collections/group', 'backbone.associations', 'coffee/src/main'
+    'backbone', 'underscore', 'gettext', 'js/models/group', 'js/collections/group',
+    'backbone.associations', 'coffee/src/main'
 ],
-function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
+function(Backbone, _, gettext, GroupModel, GroupCollection) {
     'use strict';
-    _.str = str;
     var GroupConfiguration = Backbone.AssociatedModel.extend({
         defaults: function() {
             return {
@@ -49,7 +48,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
         },
 
         reset: function() {
-            this.set(this._originalAttributes, { parse: true, validate: true });
+            this.set(this._originalAttributes, {parse: true, validate: true});
         },
 
         isDirty: function() {
@@ -84,7 +83,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
         },
 
         validate: function(attrs) {
-            if (!_.str.trim(attrs.name)) {
+            if (!attrs.name.trim()) {
                 return {
                     message: gettext('Group Configuration name is required.'),
                     attributes: {name: true}
@@ -94,7 +93,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
             if (!this.canBeEmpty && attrs.groups.length < 1) {
                 return {
                     message: gettext('There must be at least one group.'),
-                    attributes: { groups: true }
+                    attributes: {groups: true}
                 };
             } else {
                 // validate all groups
@@ -111,7 +110,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
                 if (!invalidGroups.isEmpty()) {
                     return {
                         message: gettext('All groups must have a name.'),
-                        attributes: { groups: invalidGroups.toJSON() }
+                        attributes: {groups: invalidGroups.toJSON()}
                     };
                 }
 
@@ -119,13 +118,13 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
                 if (groupNames.length !== _.uniq(groupNames).length) {
                     return {
                         message: gettext('All groups must have a unique name.'),
-                        attributes: { groups: validGroups.toJSON() }
+                        attributes: {groups: validGroups.toJSON()}
                     };
                 }
             }
         },
 
-        groupRemoved: function () {
+        groupRemoved: function() {
             this.setOriginalAttributes();
         }
     });
diff --git a/cms/static/js/views/edit_chapter.js b/cms/static/js/views/edit_chapter.js
index 895e932f349..fbf29c179a0 100644
--- a/cms/static/js/views/edit_chapter.js
+++ b/cms/static/js/views/edit_chapter.js
@@ -1,79 +1,84 @@
 /*global course */
 
-define(["js/views/baseview", "underscore", "underscore.string", "jquery", "gettext", "js/models/uploads", "js/views/uploads"],
-        function(BaseView, _, str, $, gettext, FileUploadModel, UploadDialogView) {
-    _.str = str; // used in template
-    var EditChapter = BaseView.extend({
-        initialize: function() {
-            this.template = this.loadTemplate('edit-chapter');
-            this.listenTo(this.model, "change", this.render);
-        },
-        tagName: "li",
-        className: function() {
-            return "field-group chapter chapter" + this.model.get('order');
-        },
-        render: function() {
-            this.$el.html(this.template({
-                name: this.model.get('name'),
-                asset_path: this.model.get('asset_path'),
-                order: this.model.get('order'),
-                error: this.model.validationError
-            }));
-            return this;
-        },
-        events: {
-            "change .chapter-name": "changeName",
-            "change .chapter-asset-path": "changeAssetPath",
-            "click .action-close": "removeChapter",
-            "click .action-upload": "openUploadDialog",
-            "submit": "uploadAsset"
-        },
-        changeName: function(e) {
-            if(e && e.preventDefault) { e.preventDefault(); }
-            this.model.set({
-                name: this.$(".chapter-name").val()
-            }, {silent: true});
-            return this;
-        },
-        changeAssetPath: function(e) {
-            if(e && e.preventDefault) { e.preventDefault(); }
-            this.model.set({
-                asset_path: this.$(".chapter-asset-path").val()
-            }, {silent: true});
-            return this;
-        },
-        removeChapter: function(e) {
-            if(e && e.preventDefault) { e.preventDefault(); }
-            this.model.collection.remove(this.model);
-            return this.remove();
-        },
-        openUploadDialog: function(e) {
-            if(e && e.preventDefault) { e.preventDefault(); }
-            this.model.set({
-                name: this.$("input.chapter-name").val(),
-                asset_path: this.$("input.chapter-asset-path").val()
-            });
-            var msg = new FileUploadModel({
-                title: _.template(gettext("Upload a new PDF to “<%= name %>”"))(
-                    {name: course.escape('name')}),
-                message: gettext("Please select a PDF file to upload."),
-                mimeTypes: ['application/pdf']
-            });
-            var that = this;
-            var view = new UploadDialogView({
-                model: msg,
-                onSuccess: function(response) {
-                    var options = {};
-                    if(!that.model.get('name')) {
-                        options.name = response.asset.displayname;
+define(['underscore', 'jquery', 'gettext', 'edx-ui-toolkit/js/utils/html-utils',
+        'js/views/baseview', 'js/models/uploads', 'js/views/uploads', 'text!templates/edit-chapter.underscore'],
+    function(_, $, gettext, HtmlUtils, BaseView, FileUploadModel, UploadDialogView, editChapterTemplate) {
+        'use strict';
+
+        var EditChapter = BaseView.extend({
+            initialize: function() {
+                this.template = HtmlUtils.template(editChapterTemplate);
+                this.listenTo(this.model, 'change', this.render);
+            },
+            tagName: 'li',
+            className: function() {
+                return 'field-group chapter chapter' + this.model.get('order');
+            },
+            render: function() {
+                HtmlUtils.setHtml(
+                    this.$el,
+                    this.template({
+                        name: this.model.get('name'),
+                        asset_path: this.model.get('asset_path'),
+                        order: this.model.get('order'),
+                        error: this.model.validationError
+                    })
+                );
+                return this;
+            },
+            events: {
+                'change .chapter-name': 'changeName',
+                'change .chapter-asset-path': 'changeAssetPath',
+                'click .action-close': 'removeChapter',
+                'click .action-upload': 'openUploadDialog',
+                'submit': 'uploadAsset'
+            },
+            changeName: function(e) {
+                if(e && e.preventDefault) { e.preventDefault(); }
+                this.model.set({
+                    name: this.$('.chapter-name').val()
+                }, {silent: true});
+                return this;
+            },
+            changeAssetPath: function(e) {
+                if(e && e.preventDefault) { e.preventDefault(); }
+                this.model.set({
+                    asset_path: this.$('.chapter-asset-path').val()
+                }, {silent: true});
+                return this;
+            },
+            removeChapter: function(e) {
+                if(e && e.preventDefault) { e.preventDefault(); }
+                this.model.collection.remove(this.model);
+                return this.remove();
+            },
+            openUploadDialog: function(e) {
+                if(e && e.preventDefault) { e.preventDefault(); }
+                this.model.set({
+                    name: this.$('input.chapter-name').val(),
+                    asset_path: this.$('input.chapter-asset-path').val()
+                });
+                var msg = new FileUploadModel({
+                    title: _.template(gettext('Upload a new PDF to “<%= name %>”'))(
+                        {name: course.escape('name')}),
+                    message: gettext('Please select a PDF file to upload.'),
+                    mimeTypes: ['application/pdf']
+                });
+                var that = this;
+                var view = new UploadDialogView({
+                    model: msg,
+                    onSuccess: function(response) {
+                        var options = {};
+                        if (!that.model.get('name')) {
+                            options.name = response.asset.displayname;
+                        }
+                        options.asset_path = response.asset.portable_url;
+                        that.model.set(options);
                     }
-                    options.asset_path = response.asset.portable_url;
-                    that.model.set(options);
-                }
-            });
-            view.show();
-        }
-    });
+                });
+                view.show();
+            }
+        });
 
-    return EditChapter;
-});
+        return EditChapter;
+    });
diff --git a/cms/static/js/views/experiment_group_edit.js b/cms/static/js/views/experiment_group_edit.js
index 649b080ce13..2a5f55e3fcd 100644
--- a/cms/static/js/views/experiment_group_edit.js
+++ b/cms/static/js/views/experiment_group_edit.js
@@ -3,11 +3,10 @@
  * It is expected to be backed by a Group model.
  */
 define([
-    'js/views/baseview', 'underscore', 'underscore.string', 'gettext'
+    'js/views/baseview'
 ],
-function(BaseView, _, str, gettext) {
+function(BaseView) {
     'use strict';
-    _.str = str; // used in template
     var ExperimentGroupEditView = BaseView.extend({
         tagName: 'li',
         events: {
diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml
index 42685123a55..169bd088b3c 100644
--- a/cms/static/js_test.yml
+++ b/cms/static/js_test.yml
@@ -36,7 +36,7 @@ lib_paths:
     - xmodule_js/common_static/js/vendor/jquery.cookie.js
     - xmodule_js/common_static/js/vendor/jquery.simulate.js
     - xmodule_js/common_static/common/js/vendor/underscore.js
-    - xmodule_js/common_static/js/vendor/underscore.string.min.js
+    - xmodule_js/common_static/common/js/vendor/underscore.string.js
     - xmodule_js/common_static/js/vendor/backbone-min.js
     - xmodule_js/common_static/js/vendor/backbone-associations-min.js
     - xmodule_js/common_static/js/vendor/backbone.paginator.min.js
diff --git a/cms/static/js_test_squire.yml b/cms/static/js_test_squire.yml
index 62a93331f26..1122188c051 100644
--- a/cms/static/js_test_squire.yml
+++ b/cms/static/js_test_squire.yml
@@ -35,7 +35,7 @@ lib_paths:
     - xmodule_js/common_static/js/vendor/jquery-ui.min.js
     - xmodule_js/common_static/js/vendor/jquery.cookie.js
     - xmodule_js/common_static/common/js/vendor/underscore.js
-    - xmodule_js/common_static/js/vendor/underscore.string.min.js
+    - xmodule_js/common_static/common/js/vendor/underscore.string.js
     - xmodule_js/common_static/js/vendor/backbone-min.js
     - xmodule_js/common_static/js/vendor/backbone-associations-min.js
     - xmodule_js/common_static/js/vendor/backbone.paginator.min.js
diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html
index c550fe43d20..d5b7ec6e251 100644
--- a/cms/templates/textbooks.html
+++ b/cms/templates/textbooks.html
@@ -10,7 +10,7 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json
 <%block name="bodyclass">is-signedin course view-textbooks</%block>
 
 <%block name="header_extras">
-% for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "basic-modal", "modal-button", "upload-dialog"]:
+% for template_name in ["edit-textbook", "show-textbook", "no-textbooks", "basic-modal", "modal-button", "upload-dialog"]:
   <script type="text/template" id="${template_name}-tpl">
     <%static:include path="js/${template_name}.underscore" />
   </script>
diff --git a/common/static/common/js/spec/main_requirejs.js b/common/static/common/js/spec/main_requirejs.js
index 94a40ca2def..3318f8657cd 100644
--- a/common/static/common/js/spec/main_requirejs.js
+++ b/common/static/common/js/spec/main_requirejs.js
@@ -23,7 +23,7 @@
             'sinon': 'js/vendor/sinon-1.17.0',
             'text': 'js/vendor/requirejs/text',
             'underscore': 'common/js/vendor/underscore',
-            'underscore.string': 'js/vendor/underscore.string.min',
+            'underscore.string': 'common/js/vendor/underscore.string',
             'backbone': 'js/vendor/backbone-min',
             'backbone.associations': 'js/vendor/backbone-associations-min',
             'backbone.paginator': 'js/vendor/backbone.paginator.min',
diff --git a/common/static/js/vendor/underscore.string.min.js b/common/static/js/vendor/underscore.string.min.js
deleted file mode 100644
index 659ddbed2a9..00000000000
--- a/common/static/js/vendor/underscore.string.min.js
+++ /dev/null
@@ -1 +0,0 @@
-!function(e,t){"use strict";var n=t.prototype.trim,r=t.prototype.trimRight,i=t.prototype.trimLeft,s=function(e){return e*1||0},o=function(e,t){if(t<1)return"";var n="";while(t>0)t&1&&(n+=e),t>>=1,e+=e;return n},u=[].slice,a=function(e){return e==null?"\\s":e.source?e.source:"["+p.escapeRegExp(e)+"]"},f={lt:"<",gt:">",quot:'"',apos:"'",amp:"&"},l={};for(var c in f)l[f[c]]=c;var h=function(){function e(e){return Object.prototype.toString.call(e).slice(8,-1).toLowerCase()}var n=o,r=function(){return r.cache.hasOwnProperty(arguments[0])||(r.cache[arguments[0]]=r.parse(arguments[0])),r.format.call(null,r.cache[arguments[0]],arguments)};return r.format=function(r,i){var s=1,o=r.length,u="",a,f=[],l,c,p,d,v,m;for(l=0;l<o;l++){u=e(r[l]);if(u==="string")f.push(r[l]);else if(u==="array"){p=r[l];if(p[2]){a=i[s];for(c=0;c<p[2].length;c++){if(!a.hasOwnProperty(p[2][c]))throw new Error(h('[_.sprintf] property "%s" does not exist',p[2][c]));a=a[p[2][c]]}}else p[1]?a=i[p[1]]:a=i[s++];if(/[^s]/.test(p[8])&&e(a)!="number")throw new Error(h("[_.sprintf] expecting number but found %s",e(a)));switch(p[8]){case"b":a=a.toString(2);break;case"c":a=t.fromCharCode(a);break;case"d":a=parseInt(a,10);break;case"e":a=p[7]?a.toExponential(p[7]):a.toExponential();break;case"f":a=p[7]?parseFloat(a).toFixed(p[7]):parseFloat(a);break;case"o":a=a.toString(8);break;case"s":a=(a=t(a))&&p[7]?a.substring(0,p[7]):a;break;case"u":a=Math.abs(a);break;case"x":a=a.toString(16);break;case"X":a=a.toString(16).toUpperCase()}a=/[def]/.test(p[8])&&p[3]&&a>=0?"+"+a:a,v=p[4]?p[4]=="0"?"0":p[4].charAt(1):" ",m=p[6]-t(a).length,d=p[6]?n(v,m):"",f.push(p[5]?a+d:d+a)}}return f.join("")},r.cache={},r.parse=function(e){var t=e,n=[],r=[],i=0;while(t){if((n=/^[^\x25]+/.exec(t))!==null)r.push(n[0]);else if((n=/^\x25{2}/.exec(t))!==null)r.push("%");else{if((n=/^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(t))===null)throw new Error("[_.sprintf] huh?");if(n[2]){i|=1;var s=[],o=n[2],u=[];if((u=/^([a-z_][a-z_\d]*)/i.exec(o))===null)throw new Error("[_.sprintf] huh?");s.push(u[1]);while((o=o.substring(u[0].length))!=="")if((u=/^\.([a-z_][a-z_\d]*)/i.exec(o))!==null)s.push(u[1]);else{if((u=/^\[(\d+)\]/.exec(o))===null)throw new Error("[_.sprintf] huh?");s.push(u[1])}n[2]=s}else i|=2;if(i===3)throw new Error("[_.sprintf] mixing positional and named placeholders is not (yet) supported");r.push(n)}t=t.substring(n[0].length)}return r},r}(),p={VERSION:"2.3.0",isBlank:function(e){return e==null&&(e=""),/^\s*$/.test(e)},stripTags:function(e){return e==null?"":t(e).replace(/<\/?[^>]+>/g,"")},capitalize:function(e){return e=e==null?"":t(e),e.charAt(0).toUpperCase()+e.slice(1)},chop:function(e,n){return e==null?[]:(e=t(e),n=~~n,n>0?e.match(new RegExp(".{1,"+n+"}","g")):[e])},clean:function(e){return p.strip(e).replace(/\s+/g," ")},count:function(e,n){return e==null||n==null?0:t(e).split(n).length-1},chars:function(e){return e==null?[]:t(e).split("")},swapCase:function(e){return e==null?"":t(e).replace(/\S/g,function(e){return e===e.toUpperCase()?e.toLowerCase():e.toUpperCase()})},escapeHTML:function(e){return e==null?"":t(e).replace(/[&<>"']/g,function(e){return"&"+l[e]+";"})},unescapeHTML:function(e){return e==null?"":t(e).replace(/\&([^;]+);/g,function(e,n){var r;return n in f?f[n]:(r=n.match(/^#x([\da-fA-F]+)$/))?t.fromCharCode(parseInt(r[1],16)):(r=n.match(/^#(\d+)$/))?t.fromCharCode(~~r[1]):e})},escapeRegExp:function(e){return e==null?"":t(e).replace(/([.*+?^=!:${}()|[\]\/\\])/g,"\\$1")},splice:function(e,t,n,r){var i=p.chars(e);return i.splice(~~t,~~n,r),i.join("")},insert:function(e,t,n){return p.splice(e,t,0,n)},include:function(e,n){return n===""?!0:e==null?!1:t(e).indexOf(n)!==-1},join:function(){var e=u.call(arguments),t=e.shift();return t==null&&(t=""),e.join(t)},lines:function(e){return e==null?[]:t(e).split("\n")},reverse:function(e){return p.chars(e).reverse().join("")},startsWith:function(e,n){return n===""?!0:e==null||n==null?!1:(e=t(e),n=t(n),e.length>=n.length&&e.slice(0,n.length)===n)},endsWith:function(e,n){return n===""?!0:e==null||n==null?!1:(e=t(e),n=t(n),e.length>=n.length&&e.slice(e.length-n.length)===n)},succ:function(e){return e==null?"":(e=t(e),e.slice(0,-1)+t.fromCharCode(e.charCodeAt(e.length-1)+1))},titleize:function(e){return e==null?"":t(e).replace(/(?:^|\s)\S/g,function(e){return e.toUpperCase()})},camelize:function(e){return p.trim(e).replace(/[-_\s]+(.)?/g,function(e,t){return t.toUpperCase()})},underscored:function(e){return p.trim(e).replace(/([a-z\d])([A-Z]+)/g,"$1_$2").replace(/[-\s]+/g,"_").toLowerCase()},dasherize:function(e){return p.trim(e).replace(/([A-Z])/g,"-$1").replace(/[-_\s]+/g,"-").toLowerCase()},classify:function(e){return p.titleize(t(e).replace(/_/g," ")).replace(/\s/g,"")},humanize:function(e){return p.capitalize(p.underscored(e).replace(/_id$/,"").replace(/_/g," "))},trim:function(e,r){return e==null?"":!r&&n?n.call(e):(r=a(r),t(e).replace(new RegExp("^"+r+"+|"+r+"+$","g"),""))},ltrim:function(e,n){return e==null?"":!n&&i?i.call(e):(n=a(n),t(e).replace(new RegExp("^"+n+"+"),""))},rtrim:function(e,n){return e==null?"":!n&&r?r.call(e):(n=a(n),t(e).replace(new RegExp(n+"+$"),""))},truncate:function(e,n,r){return e==null?"":(e=t(e),r=r||"...",n=~~n,e.length>n?e.slice(0,n)+r:e)},prune:function(e,n,r){if(e==null)return"";e=t(e),n=~~n,r=r!=null?t(r):"...";if(e.length<=n)return e;var i=function(e){return e.toUpperCase()!==e.toLowerCase()?"A":" "},s=e.slice(0,n+1).replace(/.(?=\W*\w*$)/g,i);return s.slice(s.length-2).match(/\w\w/)?s=s.replace(/\s*\S+$/,""):s=p.rtrim(s.slice(0,s.length-1)),(s+r).length>e.length?e:e.slice(0,s.length)+r},words:function(e,t){return p.isBlank(e)?[]:p.trim(e,t).split(t||/\s+/)},pad:function(e,n,r,i){e=e==null?"":t(e),n=~~n;var s=0;r?r.length>1&&(r=r.charAt(0)):r=" ";switch(i){case"right":return s=n-e.length,e+o(r,s);case"both":return s=n-e.length,o(r,Math.ceil(s/2))+e+o(r,Math.floor(s/2));default:return s=n-e.length,o(r,s)+e}},lpad:function(e,t,n){return p.pad(e,t,n)},rpad:function(e,t,n){return p.pad(e,t,n,"right")},lrpad:function(e,t,n){return p.pad(e,t,n,"both")},sprintf:h,vsprintf:function(e,t){return t.unshift(e),h.apply(null,t)},toNumber:function(e,n){if(e==null||e=="")return 0;e=t(e);var r=s(s(e).toFixed(~~n));return r===0&&!e.match(/^0+$/)?Number.NaN:r},numberFormat:function(e,t,n,r){if(isNaN(e)||e==null)return"";e=e.toFixed(~~t),r=r||",";var i=e.split("."),s=i[0],o=i[1]?(n||".")+i[1]:"";return s.replace(/(\d)(?=(?:\d{3})+$)/g,"$1"+r)+o},strRight:function(e,n){if(e==null)return"";e=t(e),n=n!=null?t(n):n;var r=n?e.indexOf(n):-1;return~r?e.slice(r+n.length,e.length):e},strRightBack:function(e,n){if(e==null)return"";e=t(e),n=n!=null?t(n):n;var r=n?e.lastIndexOf(n):-1;return~r?e.slice(r+n.length,e.length):e},strLeft:function(e,n){if(e==null)return"";e=t(e),n=n!=null?t(n):n;var r=n?e.indexOf(n):-1;return~r?e.slice(0,r):e},strLeftBack:function(e,t){if(e==null)return"";e+="",t=t!=null?""+t:t;var n=e.lastIndexOf(t);return~n?e.slice(0,n):e},toSentence:function(e,t,n,r){t=t||", ",n=n||" and ";var i=e.slice(),s=i.pop();return e.length>2&&r&&(n=p.rtrim(t)+n),i.length?i.join(t)+n+s:s},toSentenceSerial:function(){var e=u.call(arguments);return e[3]=!0,p.toSentence.apply(p,e)},slugify:function(e){if(e==null)return"";var n="ąàáäâãåæćęèéëêìíïîłńòóöôõøùúüûñçżź",r="aaaaaaaaceeeeeiiiilnoooooouuuunczz",i=new RegExp(a(n),"g");return e=t(e).toLowerCase().replace(i,function(e){var t=n.indexOf(e);return r.charAt(t)||"-"}),p.dasherize(e.replace(/[^\w\s-]/g,""))},surround:function(e,t){return[t,e,t].join("")},quote:function(e){return p.surround(e,'"')},exports:function(){var e={};for(var t in this){if(!this.hasOwnProperty(t)||t.match(/^(?:include|contains|reverse)$/))continue;e[t]=this[t]}return e},repeat:function(e,n,r){if(e==null)return"";n=~~n;if(r==null)return o(t(e),n);for(var i=[];n>0;i[--n]=e);return i.join(r)},levenshtein:function(e,n){if(e==null&&n==null)return 0;if(e==null)return t(n).length;if(n==null)return t(e).length;e=t(e),n=t(n);var r=[],i,s;for(var o=0;o<=n.length;o++)for(var u=0;u<=e.length;u++)o&&u?e.charAt(u-1)===n.charAt(o-1)?s=i:s=Math.min(r[u],r[u-1],i)+1:s=o+u,i=r[u],r[u]=s;return r.pop()}};p.strip=p.trim,p.lstrip=p.ltrim,p.rstrip=p.rtrim,p.center=p.lrpad,p.rjust=p.lpad,p.ljust=p.rpad,p.contains=p.include,p.q=p.quote,typeof exports!="undefined"?(typeof module!="undefined"&&module.exports&&(module.exports=p),exports._s=p):typeof define==="function"&&define.amd?define("underscore.string",[],function(){return p}):(e._=e._||{},e._.string=e._.str=p)}(this,String);
\ No newline at end of file
diff --git a/common/static/js_test.yml b/common/static/js_test.yml
index 4c0e87e5ecd..8491ee18c6b 100644
--- a/common/static/js_test.yml
+++ b/common/static/js_test.yml
@@ -34,7 +34,7 @@ lib_paths:
     - js/vendor/jquery.truncate.js
     - js/vendor/mustache.js
     - common/js/vendor/underscore.js
-    - js/vendor/underscore.string.min.js
+    - common/js/vendor/underscore.string.js
     - js/vendor/backbone-min.js
     - js/vendor/jquery.timeago.js
     - js/vendor/URI.min.js
diff --git a/common/static/js_test_requirejs.yml b/common/static/js_test_requirejs.yml
index c0802739478..123bd998271 100644
--- a/common/static/js_test_requirejs.yml
+++ b/common/static/js_test_requirejs.yml
@@ -33,8 +33,8 @@ lib_paths:
     - js/vendor/jasmine-imagediff.js
     - js/vendor/jquery.simulate.js
     - js/vendor/jquery.truncate.js
-    - js/vendor/underscore-min.js
-    - js/vendor/underscore.string.min.js
+    - common/js/vendor/underscore.js
+    - common/js/vendor/underscore.string.js
     - js/vendor/backbone-min.js
     - js/vendor/backbone.paginator.min.js
     - js/vendor/jquery.timeago.js
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js
index 41df1cfffd9..5edb9c5da55 100644
--- a/lms/static/js/spec/main.js
+++ b/lms/static/js/spec/main.js
@@ -30,7 +30,7 @@
             'moment-with-locales': 'xmodule_js/common_static/js/vendor/moment-with-locales.min',
             'text': 'xmodule_js/common_static/js/vendor/requirejs/text',
             'underscore': 'xmodule_js/common_static/common/js/vendor/underscore',
-            'underscore.string': 'xmodule_js/common_static/js/vendor/underscore.string.min',
+            'underscore.string': 'xmodule_js/common_static/common/js/vendor/underscore.string',
             'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
             'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
             'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min',
diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml
index 1e7ca63e49b..103ad320678 100644
--- a/lms/static/js_test.yml
+++ b/lms/static/js_test.yml
@@ -57,7 +57,7 @@ lib_paths:
     - xmodule_js/src/xmodule.js
     - xmodule_js/common_static/js/src/
     - xmodule_js/common_static/common/js/vendor/underscore.js
-    - xmodule_js/common_static/js/vendor/underscore.string.min.js
+    - xmodule_js/common_static/common/js/vendor/underscore.string.js
     - xmodule_js/common_static/js/vendor/backbone-min.js
     - xmodule_js/common_static/js/vendor/backbone.paginator.min.js
     - xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js
diff --git a/lms/static/lms/js/require-config.js b/lms/static/lms/js/require-config.js
index e7838b06ca4..0f5dbc998ba 100644
--- a/lms/static/lms/js/require-config.js
+++ b/lms/static/lms/js/require-config.js
@@ -29,11 +29,11 @@
         };
         defineDependency("jQuery", "jquery");
         defineDependency("_", "underscore");
-        if (window._ && window._.str) {
-            define("underscore.string", [], function () {return window._.str;});
-        }
-        else {
-            console.error("Expected _.str (underscore.string) to be on the window object, but not found.");
+        defineDependency("s", "underscore.string");
+        // Underscore.string no longer installs itself directly on "_". For compatibility with existing
+        // code, add it to "_" with its previous name.
+        if (window._ && window.s) {
+            window._.str = window.s;
         }
         defineDependency("gettext", "gettext");
         defineDependency("Logger", "logger");
@@ -62,7 +62,7 @@
             "backbone-super": "js/vendor/backbone-super",
             "backbone.paginator": "js/vendor/backbone.paginator.min",
             "underscore": "common/js/vendor/underscore",
-            "underscore.string": "js/vendor/underscore.string.min",
+            "underscore.string": "common/js/vendor/underscore.string",
             "jquery": "js/vendor/jquery.min",
             "jquery.cookie": "js/vendor/jquery.cookie",
             'jquery.timeago': 'js/vendor/jquery.timeago',
diff --git a/package.json b/package.json
index 494145643d2..5b9cffa68b5 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
     "edx-ui-toolkit": "0.9.0",
     "requirejs": "~2.1.22",
     "uglify-js": "2.4.24",
-    "underscore": "~1.8.3"
+    "underscore": "~1.8.3",
+    "underscore.string": "~3.3.4"
   },
   "devDependencies": {
     "jshint": "^2.7.0",
diff --git a/pavelib/assets.py b/pavelib/assets.py
index 4670a196fe6..45ff5887a74 100644
--- a/pavelib/assets.py
+++ b/pavelib/assets.py
@@ -39,7 +39,8 @@ COMMON_LOOKUP_DIRS = [
 # A list of NPM installed libraries that should be copied into the common
 # static directory.
 NPM_INSTALLED_LIBRARIES = [
-    'underscore/underscore.js'
+    'underscore/underscore.js',
+    'underscore.string/dist/underscore.string.js'
 ]
 
 # Directory to install static vendor files
-- 
GitLab