diff --git a/cms/envs/common.py b/cms/envs/common.py index 8551a56c41..d7c9e6bb90 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -235,8 +235,7 @@ PIPELINE_JS = { 'source_filenames': sorted( rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') - ) + ['js/hesitate.js', 'js/base.js', - 'js/models/feedback.js', 'js/views/feedback.js', + ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js', 'js/models/section.js', 'js/views/section.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/views/assets.js'], diff --git a/cms/static/coffee/spec/models/feedback_spec.coffee b/cms/static/coffee/spec/models/feedback_spec.coffee deleted file mode 100644 index 6ddac41ebf..0000000000 --- a/cms/static/coffee/spec/models/feedback_spec.coffee +++ /dev/null @@ -1,34 +0,0 @@ -describe "CMS.Models.SystemFeedback", -> - beforeEach -> - @model = new CMS.Models.SystemFeedback() - - it "should have an empty message by default", -> - expect(@model.get("message")).toEqual("") - - it "should have an empty title by default", -> - expect(@model.get("title")).toEqual("") - - it "should not have an intent set by default", -> - expect(@model.get("intent")).toBeNull() - - -describe "CMS.Models.WarningMessage", -> - beforeEach -> - @model = new CMS.Models.WarningMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("warning") - -describe "CMS.Models.ErrorMessage", -> - beforeEach -> - @model = new CMS.Models.ErrorMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("error") - -describe "CMS.Models.ConfirmationMessage", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("confirmation") diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index 3e7d080a7c..61c5652ff3 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -18,79 +18,105 @@ beforeEach -> else return trimmedText.indexOf(text) != -1; -describe "CMS.Views.Alert as base class", -> +describe "CMS.Views.SystemFeedback", -> beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ + @options = title: "Portal" message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) # it will be interesting to see when this.render is called, so lets spy on it - spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough() + @renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough() + @showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough() + @hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough() - it "renders on initalize", -> - view = new CMS.Views.Alert({model: @model}) - expect(view.render).toHaveBeenCalled() + it "requires a type and an intent", -> + neither = => + new CMS.Views.SystemFeedback(@options) + noType = => + options = $.extend({}, @options) + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + noIntent = => + options = $.extend({}, @options) + options.type = "alert" + new CMS.Views.SystemFeedback(options) + both = => + options = $.extend({}, @options) + options.type = "alert" + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + + expect(neither).toThrow() + expect(noType).toThrow() + expect(noIntent).toThrow() + expect(both).not.toThrow() + + # for simplicity, we'll use CMS.Views.Alert.Confirmation from here on, + # which extends and proxies to CMS.Views.SystemFeedback + + it "does not show on initalize", -> + view = new CMS.Views.Alert.Confirmation(@options) + expect(@renderSpy).not.toHaveBeenCalled() + expect(@showSpy).not.toHaveBeenCalled() it "renders the template", -> - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options) + view.show() + expect(view.$(".action-close")).toBeDefined() expect(view.$('.wrapper')).toBeShown() - expect(view.$el).toContainText(@model.get("title")) - expect(view.$el).toContainText(@model.get("message")) + expect(view.$el).toContainText(@options.title) + expect(view.$el).toContainText(@options.message) it "close button sends a .hide() message", -> - spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough() - - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options).show() view.$(".action-close").click() - expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled() + expect(@hideSpy).toHaveBeenCalled() expect(view.$('.wrapper')).toBeHiding() describe "CMS.Views.Prompt", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ - title: "Portal" - message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) - # for some reason, expect($("body")) blows up the test runner, so this test # just exercises the Prompt rather than asserting on anything. Best I can # do for now. :( it "changes class on body", -> # expect($("body")).not.toHaveClass("prompt-is-shown") - view = new CMS.Views.Prompt({model: @model}) + view = new CMS.Views.Prompt.Confirmation({ + title: "Portal" + message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" + }) # expect($("body")).toHaveClass("prompt-is-shown") view.hide() # expect($("body")).not.toHaveClass("prompt-is-shown") -describe "CMS.Views.Alert click events", -> +describe "CMS.Views.SystemFeedback click events", -> beforeEach -> - @model = new CMS.Models.WarningMessage( + @primaryClickSpy = jasmine.createSpy('primaryClick') + @secondaryClickSpy = jasmine.createSpy('secondaryClick') + @view = new CMS.Views.Notification.Warning( title: "Unsaved", message: "Your content is currently Unsaved.", actions: primary: text: "Save", class: "save-button", - click: jasmine.createSpy('primaryClick') + click: @primaryClickSpy secondary: [{ text: "Revert", class: "cancel-button", - click: jasmine.createSpy('secondaryClick') + click: @secondaryClickSpy }] - ) - - @view = new CMS.Views.Alert({model: @model}) + @view.show() it "should trigger the primary event on a primary click", -> - @view.primaryClick() - expect(@model.get('actions').primary.click).toHaveBeenCalled() + @view.$(".action-primary").click() + expect(@primaryClickSpy).toHaveBeenCalled() + expect(@secondaryClickSpy).not.toHaveBeenCalled() it "should trigger the secondary event on a secondary click", -> - @view.secondaryClick() - expect(@model.get('actions').secondary[0].click).toHaveBeenCalled() + @view.$(".action-secondary").click() + expect(@secondaryClickSpy).toHaveBeenCalled() + expect(@primaryClickSpy).not.toHaveBeenCalled() it "should apply class to primary action", -> expect(@view.$(".action-primary")).toHaveClass("save-button") @@ -100,20 +126,19 @@ describe "CMS.Views.Alert click events", -> describe "CMS.Views.Notification minShown and maxShown", -> beforeEach -> - @model = new CMS.Models.SystemFeedback( - intent: "saving" - title: "Saving" - ) - spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough() - spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough() + @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') + @showSpy.andCallThrough() + @hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide') + @hideSpy.andCallThrough() @clock = sinon.useFakeTimers() afterEach -> + delete CMS.Views.Notification.active; @clock.restore() it "a minShown view should not hide too quickly", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # call hide() on it, but the minShown should prevent it from hiding right away @@ -125,8 +150,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view should hide by itself", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the maxShown timeout to expire, and check again @@ -134,13 +159,13 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a minShown view can stay visible longer", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the minShown timeout to expire, and check again @clock.tick(1001) - expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled() + expect(@hideSpy).not.toHaveBeenCalled() expect(view.$('.wrapper')).toBeShown() # can now hide immediately @@ -148,8 +173,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view can hide early", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait 50 milliseconds, and hide it early @@ -162,7 +187,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a view can have both maxShown and minShown", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000}) + view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000}) + view.show() # can't hide early @clock.tick(50) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index efcd869113..5ac104d162 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -18,11 +18,11 @@ $ -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> if ajaxSettings.notifyOnError is false return - msg = new CMS.Models.ErrorMessage( + msg = new CMS.Views.Notification.Error( "title": gettext("Studio's having trouble saving your work") "message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") ) - new CMS.Views.Notification({model: msg}) + msg.show() window.onTouchBasedDevice = -> navigator.userAgent.match /iPhone|iPod|iPad/i diff --git a/cms/static/js/models/feedback.js b/cms/static/js/models/feedback.js deleted file mode 100644 index d57cffa779..0000000000 --- a/cms/static/js/models/feedback.js +++ /dev/null @@ -1,55 +0,0 @@ -CMS.Models.SystemFeedback = Backbone.Model.extend({ - defaults: { - "intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc - "title": "", - "message": "" - /* could also have an "actions" hash: here is an example demonstrating - the expected structure - "actions": { - "primary": { - "text": "Save", - "class": "action-save", - "click": function() { - // do something when Save is clicked - // `this` refers to the model - } - }, - "secondary": [ - { - "text": "Cancel", - "class": "action-cancel", - "click": function() {} - }, { - "text": "Discard Changes", - "class": "action-discard", - "click": function() {} - } - ] - } - */ - } -}); - -CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "error" - }) -}); - -CMS.Models.ConfirmAssetDeleteMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "confirmation" - }) -}); diff --git a/cms/static/js/models/section.js b/cms/static/js/models/section.js index 467a2709a6..902585c58c 100644 --- a/cms/static/js/models/section.js +++ b/cms/static/js/models/section.js @@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({ }, showNotification: function() { if(!this.msg) { - this.msg = new CMS.Models.SystemFeedback({ - intent: "saving", - title: gettext("Saving…") - }); - } - if(!this.msgView) { - this.msgView = new CMS.Views.Notification({ - model: this.msg, + this.msg = new CMS.Views.Notification.Saving({ + title: gettext("Saving…"), closeIcon: false, minShown: 1250 }); } - this.msgView.show(); + this.msg.show(); }, hideNotification: function() { - if(!this.msgView) { return; } - this.msgView.hide(); + if(!this.msg) { return; } + this.msg.hide(); } }); diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 1a1a33ec1b..b04fb6e3d1 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -1,39 +1,64 @@ -CMS.Views.Alert = Backbone.View.extend({ +CMS.Views.SystemFeedback = Backbone.View.extend({ options: { - type: "alert", + title: "", + message: "", + intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc + type: null, // "alert", "notification", or "prompt": set by subclass shown: true, // is this view currently being shown? icon: true, // should we render an icon related to the message intent? closeIcon: true, // should we render a close button in the top right corner? minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) + + /* could also have an "actions" hash: here is an example demonstrating + the expected structure + actions: { + primary: { + "text": "Save", + "class": "action-save", + "click": function(view) { + // do something when Save is clicked + } + }, + secondary: [ + { + "text": "Cancel", + "class": "action-cancel", + "click": function(view) {} + }, { + "text": "Discard Changes", + "class": "action-discard", + "click": function(view) {} + } + ] + } + */ }, initialize: function() { + if(!this.options.type) { + throw "SystemFeedback: type required (given " + + JSON.stringify(this.options) + ")"; + } + if(!this.options.intent) { + throw "SystemFeedback: intent required (given " + + JSON.stringify(this.options) + ")"; + } var tpl = $("#system-feedback-tpl").text(); if(!tpl) { console.error("Couldn't load system-feedback template"); } this.template = _.template(tpl); this.setElement($("#page-"+this.options.type)); - this.listenTo(this.model, 'change', this.render); - return this.show(); - }, - render: function() { - var attrs = $.extend({}, this.options, this.model.attributes); - this.$el.html(this.template(attrs)); return this; }, - events: { - "click .action-close": "hide", - "click .action-primary": "primaryClick", - "click .action-secondary": "secondaryClick" - }, + // public API: show() and hide() show: function() { clearTimeout(this.hideTimeout); this.options.shown = true; this.shownAt = new Date(); this.render(); if($.isNumeric(this.options.maxShown)) { - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.maxShown); } return this; @@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({ this.options.minShown > new Date() - this.shownAt) { clearTimeout(this.hideTimeout); - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.minShown - (new Date() - this.shownAt)); } else { this.options.shown = false; @@ -52,40 +77,63 @@ CMS.Views.Alert = Backbone.View.extend({ } return this; }, - primaryClick: function() { - var actions = this.model.get("actions"); + // the rest of the API should be considered semi-private + events: { + "click .action-close": "hide", + "click .action-primary": "primaryClick", + "click .action-secondary": "secondaryClick" + }, + render: function() { + // there can be only one active view of a given type at a time: only + // one alert, only one notification, only one prompt. Therefore, we'll + // use a singleton approach. + var parent = CMS.Views[_.str.capitalize(this.options.type)]; + if(parent && parent.active && parent.active !== this) { + parent.active.stopListening(); + } + this.$el.html(this.template(this.options)); + parent.active = this; + return this; + }, + primaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var primary = actions.primary; if(!primary) { return; } if(primary.click) { - primary.click.call(this.model, this); + primary.click.call(event.target, this, event); } }, - secondaryClick: function(e) { - var actions = this.model.get("actions"); + secondaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var secondaryList = actions.secondary; if(!secondaryList) { return; } // which secondary action was clicked? var i = 0; // default to the first secondary action (easier for testing) - if(e && e.target) { - i = _.indexOf(this.$(".action-secondary"), e.target); + if(event && event.target) { + i = _.indexOf(this.$(".action-secondary"), event.target); } - var secondary = this.model.get("actions").secondary[i]; + var secondary = secondaryList[i]; if(secondary.click) { - secondary.click.call(this.model, this); + secondary.click.call(event.target, this, event); } } }); -CMS.Views.Notification = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Alert = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { + type: "alert" + }) +}); +CMS.Views.Notification = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "notification", closeIcon: false }) }); -CMS.Views.Prompt = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "prompt", closeIcon: false, icon: false @@ -98,6 +146,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ $body.removeClass('prompt-is-shown'); } // super() in Javascript has awkward syntax :( - return CMS.Views.Alert.prototype.render.apply(this, arguments); + return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments); } }); + +// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation, +// CMS.Views.Prompt.StepRequired, etc +var capitalCamel, types, intents; +capitalCamel = _.compose(_.str.capitalize, _.str.camelize); +types = ["alert", "notification", "prompt"]; +intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"]; +_.each(types, function(type) { + _.each(intents, function(intent) { + // "class" is a reserved word in Javascript, so use "klass" instead + var klass, subklass; + klass = CMS.Views[capitalCamel(type)]; + subklass = klass.extend({ + options: $.extend({}, klass.prototype.options, { + type: type, + intent: intent + }) + }); + klass[capitalCamel(intent)] = subklass; + }); +}); diff --git a/cms/static/js/views/section.js b/cms/static/js/views/section.js index 622249414d..eccc547a06 100644 --- a/cms/static/js/views/section.js +++ b/cms/static/js/views/section.js @@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({ showInvalidMessage: function(model, error, options) { model.set("name", model.previous("name")); var that = this; - var msg = new CMS.Models.ErrorMessage({ + var prompt = new CMS.Views.Prompt.Error({ title: gettext("Your change could not be saved"), message: error, actions: { @@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ } } }); - new CMS.Views.Prompt({model: msg}); + prompt.show(); } }); diff --git a/cms/templates/base.html b/cms/templates/base.html index 07587860e5..cf6431f538 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -54,7 +54,6 @@ -