From e8f40bcf963065ae48808aefbec7946ed4d620d5 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 13 Aug 2013 13:22:55 -0400 Subject: [PATCH] Generalize file uploader. Previously the file upload dialog was PDF- and textbook-specific. The changes are adding parameters to the FileUpload model for the file type, and adding an onSuccess callback to the UploadDialog view. Also moved upload-specific SASS into its own file. --- cms/envs/common.py | 1 + .../coffee/spec/views/textbook_spec.coffee | 12 +- cms/static/js/models/textbook.js | 22 +- cms/static/js/models/uploads.js | 23 ++ cms/static/js/views/textbook.js | 122 +--------- cms/static/js/views/uploads.js | 105 +++++++++ cms/static/sass/base-style.scss | 1 + cms/static/sass/views/_textbooks.scss | 209 ------------------ cms/static/sass/views/_uploads.scss | 209 ++++++++++++++++++ cms/templates/textbooks.html | 2 +- common/test/data/uploads/test | 2 +- 11 files changed, 363 insertions(+), 345 deletions(-) create mode 100644 cms/static/js/models/uploads.js create mode 100644 cms/static/js/views/uploads.js create mode 100644 cms/static/sass/views/_uploads.scss diff --git a/cms/envs/common.py b/cms/envs/common.py index 9d246edece9d..e56ed986683b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -244,6 +244,7 @@ 'js/models/course.js', 'js/models/section.js', 'js/views/section.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', + 'js/models/uploads.js', 'js/views/uploads.js', 'js/models/textbook.js', 'js/views/textbook.js', 'js/views/assets.js', 'js/utility.js'], 'output_filename': 'js/cms-application.js', diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee index 981659abfa9c..5185c9fb471c 100644 --- a/cms/static/coffee/spec/views/textbook_spec.coffee +++ b/cms/static/coffee/spec/views/textbook_spec.coffee @@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", -> @view.render().$(".action-upload").click() ctorOptions = uploadSpies.constructor.mostRecentCall.args[0] expect(ctorOptions.model.get('title')).toMatch(/abcde/) - expect(ctorOptions.chapter).toBe(@model) + expect(typeof ctorOptions.onSuccess).toBe('function') expect(uploadSpies.show).toHaveBeenCalled() it "saves content when opening upload dialog", -> @@ -323,7 +323,15 @@ describe "CMS.Views.UploadDialog", -> @model = new CMS.Models.FileUpload() @chapter = new CMS.Models.Chapter() - @view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter}) + @view = new CMS.Views.UploadDialog( + model: @model + onSuccess: (response) => + options = {} + if !@chapter.get('name') + options.name = response.displayname + options.asset_path = response.url + @chapter.set(options) + ) spyOn(@view, 'remove').andCallThrough() # create mock file input, so that we aren't subject to browser restrictions diff --git a/cms/static/js/models/textbook.js b/cms/static/js/models/textbook.js index cdd86023dcdf..72c9dfb0bed9 100644 --- a/cms/static/js/models/textbook.js +++ b/cms/static/js/models/textbook.js @@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({ return this.length === 0 || this.every(function(m) { return m.isEmpty(); }); } }); -CMS.Models.FileUpload = Backbone.Model.extend({ - defaults: { - "title": "", - "message": "", - "selectedFile": null, - "uploading": false, - "uploadedBytes": 0, - "totalBytes": 0, - "finished": false - }, - // NOTE: validation functions should return non-internationalized error - // messages. The messages will be passed through gettext in the template. - validate: function(attrs, options) { - if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") { - return { - message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.", - attributes: {selectedFile: true} - }; - } - } -}); + diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js new file mode 100644 index 000000000000..ba557306eb1f --- /dev/null +++ b/cms/static/js/models/uploads.js @@ -0,0 +1,23 @@ +CMS.Models.FileUpload = Backbone.Model.extend({ + defaults: { + "title": "", + "message": "", + "selectedFile": null, + "uploading": false, + "uploadedBytes": 0, + "totalBytes": 0, + "finished": false, + "mimeType": "application/pdf", + "fileType": "PDF" + }, + // NOTE: validation functions should return non-internationalized error + // messages. The messages will be passed through gettext in the template. + validate: function(attrs, options) { + if(attrs.selectedFile && attrs.selectedFile.type !== this.attributes.mimeType) { + return { + message: "Only " + this.attributes.fileType + " files can be uploaded. Please select a file ending in ." + this.attributes.fileType.toLowerCase() + " to upload.", + attributes: {selectedFile: true} + }; + } + } +}); diff --git a/cms/static/js/views/textbook.js b/cms/static/js/views/textbook.js index f666143c0a44..992ac5cd534e 100644 --- a/cms/static/js/views/textbook.js +++ b/cms/static/js/views/textbook.js @@ -245,118 +245,18 @@ CMS.Views.EditChapter = Backbone.View.extend({ {name: section.escape('name')}), message: "Files must be in PDF format." }); - var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model}); - $(".wrapper-view").after(view.show().el); - } -}); - -CMS.Views.UploadDialog = Backbone.View.extend({ - options: { - shown: true, - successMessageTimeout: 2000 // 2 seconds - }, - initialize: function() { - this.template = _.template($("#upload-dialog-tpl").text()); - this.listenTo(this.model, "change", this.render); - }, - render: function() { - var isValid = this.model.isValid(); - var selectedFile = this.model.get('selectedFile'); - var oldInput = this.$("input[type=file]").get(0); - this.$el.html(this.template({ - shown: this.options.shown, - url: CMS.URL.UPLOAD_ASSET, - title: this.model.escape('title'), - message: this.model.escape('message'), - selectedFile: selectedFile, - uploading: this.model.get('uploading'), - uploadedBytes: this.model.get('uploadedBytes'), - totalBytes: this.model.get('totalBytes'), - finished: this.model.get('finished'), - error: this.model.validationError - })); - // Ideally, we'd like to tell the browser to pre-populate the - // with the selectedFile if we have one -- but - // browser security prohibits that. So instead, we'll swap out the - // new input (that has no file selected) with the old input (that - // already has the selectedFile selected). However, we only want to do - // this if the selected file is valid: if it isn't, we want to render - // a blank input to prompt the user to upload a different (valid) file. - if (selectedFile && isValid) { - $(oldInput).removeClass("error"); - this.$('input[type=file]').replaceWith(oldInput); - } - return this; - }, - events: { - "change input[type=file]": "selectFile", - "click .action-cancel": "hideAndRemove", - "click .action-upload": "upload" - }, - selectFile: function(e) { - this.model.set({ - selectedFile: e.target.files[0] || null - }); - }, - show: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - this.options.shown = true; - $body.addClass('dialog-is-shown'); - return this.render(); - }, - hide: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - this.options.shown = false; - $body.removeClass('dialog-is-shown'); - return this.render(); - }, - hideAndRemove: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - return this.hide().remove(); - }, - upload: function(e) { - this.model.set('uploading', true); - this.$("form").ajaxSubmit({ - success: _.bind(this.success, this), - error: _.bind(this.error, this), - uploadProgress: _.bind(this.progress, this), - data: { - // don't show the generic error notification; we're in a modal, - // and we're better off modifying it instead. - notifyOnError: false - } - }); - }, - progress: function(event, position, total, percentComplete) { - this.model.set({ - "uploadedBytes": position, - "totalBytes": total - }); - }, - success: function(response, statusText, xhr, form) { - this.model.set({ - uploading: false, - finished: true - }); - var chapter = this.options.chapter; - if(chapter) { - var options = {}; - if(!chapter.get("name")) { - options.name = response.displayname; - } - options.asset_path = response.url; - chapter.set(options); - } var that = this; - this.removalTimeout = setTimeout(function() { - that.hide().remove(); - }, this.options.successMessageTimeout); - }, - error: function() { - this.model.set({ - "uploading": false, - "uploadedBytes": 0, - "title": gettext("We're sorry, there was an error") + var view = new CMS.Views.UploadDialog({ + model: msg, + onSuccess: function(response) { + var options = {}; + if(!that.model.get('name')) { + options.name = response.displayname; + } + options.asset_path = response.url; + that.model.set(options); + }, }); + $(".wrapper-view").after(view.show().el); } }); diff --git a/cms/static/js/views/uploads.js b/cms/static/js/views/uploads.js new file mode 100644 index 000000000000..9ce111e805b8 --- /dev/null +++ b/cms/static/js/views/uploads.js @@ -0,0 +1,105 @@ +CMS.Views.UploadDialog = Backbone.View.extend({ + options: { + shown: true, + successMessageTimeout: 2000 // 2 seconds + }, + initialize: function() { + this.template = _.template($("#upload-dialog-tpl").text()); + this.listenTo(this.model, "change", this.render); + }, + render: function() { + var isValid = this.model.isValid(); + var selectedFile = this.model.get('selectedFile'); + var oldInput = this.$("input[type=file]").get(0); + this.$el.html(this.template({ + shown: this.options.shown, + url: CMS.URL.UPLOAD_ASSET, + title: this.model.escape('title'), + message: this.model.escape('message'), + selectedFile: selectedFile, + uploading: this.model.get('uploading'), + uploadedBytes: this.model.get('uploadedBytes'), + totalBytes: this.model.get('totalBytes'), + finished: this.model.get('finished'), + error: this.model.validationError + })); + // Ideally, we'd like to tell the browser to pre-populate the + // with the selectedFile if we have one -- but + // browser security prohibits that. So instead, we'll swap out the + // new input (that has no file selected) with the old input (that + // already has the selectedFile selected). However, we only want to do + // this if the selected file is valid: if it isn't, we want to render + // a blank input to prompt the user to upload a different (valid) file. + if (selectedFile && isValid) { + $(oldInput).removeClass("error"); + this.$('input[type=file]').replaceWith(oldInput); + } + return this; + }, + events: { + "change input[type=file]": "selectFile", + "click .action-cancel": "hideAndRemove", + "click .action-upload": "upload" + }, + selectFile: function(e) { + this.model.set({ + selectedFile: e.target.files[0] || null + }); + }, + show: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.options.shown = true; + $body.addClass('dialog-is-shown'); + return this.render(); + }, + hide: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.options.shown = false; + $body.removeClass('dialog-is-shown'); + return this.render(); + }, + hideAndRemove: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + return this.hide().remove(); + }, + upload: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.model.set('uploading', true); + this.$("form").ajaxSubmit({ + success: _.bind(this.success, this), + error: _.bind(this.error, this), + uploadProgress: _.bind(this.progress, this), + data: { + // don't show the generic error notification; we're in a modal, + // and we're better off modifying it instead. + notifyOnError: false + } + }); + }, + progress: function(event, position, total, percentComplete) { + this.model.set({ + "uploadedBytes": position, + "totalBytes": total + }); + }, + success: function(response, statusText, xhr, form) { + this.model.set({ + uploading: false, + finished: true + }); + if(this.options.onSuccess) { + this.options.onSuccess(response, statusText, xhr, form); + } + var that = this; + this.removalTimeout = setTimeout(function() { + that.hide().remove(); + }, this.options.successMessageTimeout); + }, + error: function() { + this.model.set({ + "uploading": false, + "uploadedBytes": 0, + "title": gettext("We're sorry, there was an error") + }); + } +}); diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index 70110895274f..757ffe02bd42 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -59,6 +59,7 @@ @import 'views/users'; @import 'views/checklists'; @import 'views/textbooks'; +@import 'views/uploads'; // temp - inherited @import 'assets/content-types'; diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index b83d22414bb2..ac644b6ff783 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -370,213 +370,4 @@ body.course.textbooks { .content-supplementary { width: flex-grid(3, 12); } - - // dialog -.wrapper-dialog { - @extend .ui-depth5; - @include transition(all 0.05s ease-in-out); - position: fixed; - top: 0; - background: $black-t2; - width: 100%; - height: 100%; - text-align: center; - - &:before { - content: ''; - display: inline-block; - height: 100%; - vertical-align: middle; - margin-right: -0.25em; /* Adjusts for spacing */ - } - - .dialog { - @include box-sizing(border-box); - box-shadow: 0px 0px 7px $shadow-d1; - border-radius: ($baseline/5); - background-color: $gray-l4; - display: inline-block; - vertical-align: middle; - width: $baseline*23; - padding: 7px; - text-align: left; - - .title { - @extend .t-title5; - margin-bottom: ($baseline/2); - font-weight: 600; - color: $black; - } - - .message { - @extend .t-copy-sub2; - color: $gray; - } - - .error { - color: $white; - } - - form { - padding: 0; - - .form-content { - box-shadow: 0 0 3px $shadow-d1; - padding: ($baseline*1.5); - background-color: $white; - } - - input[type="file"] { - @extend .t-copy-sub2; - } - - .status-upload { - height: 30px; - margin-top: $baseline; - - .wrapper-progress { - box-shadow: inset 0 0 3px $shadow-d1; - display: block; - border-radius: ($baseline*0.75); - background-color: $gray-l5; - padding: 1px 8px 2px 8px; - height: 25px; - - progress { - display: inline-block; - vertical-align: middle; - width: 100%; - border: none; - border-radius: ($baseline*0.75); - background-color: $gray-l5; - - &::-webkit-progress-bar { - background-color: transparent; - border-radius: ($baseline*0.75); - } - - &::-webkit-progress-value { - background-color: $pink; - border-radius: ($baseline*0.75); - } - - &::-moz-progress-bar { - background-color: $pink; - border-radius: ($baseline*0.75); - } - - } - - } - - .message-status { - @include border-top-radius(2px); - @include box-sizing(border-box); - @include font-size(14); - display: none; - border-bottom: 2px solid $yellow; - margin: 0 0 20px 0; - padding: 10px 20px; - font-weight: 500; - background: $paleYellow; - - .text { - display: inline-block; - } - - &.error { - border-color: $red-d2; - background: $red-l1; - color: $white; - } - - &.confirm { - border-color: $green-d2; - background: $green-l1; - color: $white; - } - - &.is-shown { - display: block; - } - } - } - - .actions { - padding: ($baseline*0.75) $baseline ($baseline/2) $baseline; - - - - .action-item { - @extend .t-action4; - display: inline-block; - margin-right: ($baseline*0.75); - - &:last-child { - margin-right: 0; - } - } - - .action-primary { - @include blue-button(); - @include font-size(12); // needed due to bad button mixins for now - border-color: $blue-d1; - color: $white; - } - - a { - color: $blue; - - &:hover { - color: $blue-s2; - } - } - - } - - } - - } - -} - -// ==================== - -// js enabled -.js { - - // dialog set-up - .wrapper-dialog { - visibility: hidden; - pointer-events: none; - - .dialog { - opacity: 0; - } - } - - // dialog showing/hiding - &.dialog-is-shown { - - .wrapper-dialog { - -webkit-filter: blur(2px) grayscale(25%); - filter: blur(2px) grayscale(25%); - } - - .wrapper-dialog.is-shown { - visibility: visible; - pointer-events: auto; - - .dialog { - opacity: 1.0; - } - } - } - -} - - - - - } diff --git a/cms/static/sass/views/_uploads.scss b/cms/static/sass/views/_uploads.scss new file mode 100644 index 000000000000..f564ac1c1309 --- /dev/null +++ b/cms/static/sass/views/_uploads.scss @@ -0,0 +1,209 @@ +// studio - views - uploads +// ======================== + +body.course.file-upload-dialog { + + // dialog + .wrapper-dialog { + @extend .ui-depth5; + @include transition(all 0.05s ease-in-out); + position: fixed; + top: 0; + background: $black-t2; + width: 100%; + height: 100%; + text-align: center; + + &:before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + margin-right: -0.25em; /* Adjusts for spacing */ + } + + .dialog { + @include box-sizing(border-box); + box-shadow: 0px 0px 7px $shadow-d1; + border-radius: ($baseline/5); + background-color: $gray-l4; + display: inline-block; + vertical-align: middle; + width: $baseline*23; + padding: 7px; + text-align: left; + + .title { + @extend .t-title5; + margin-bottom: ($baseline/2); + font-weight: 600; + color: $black; + } + + .message { + @extend .t-copy-sub2; + color: $gray; + } + + .error { + color: $white; + } + + form { + padding: 0; + + .form-content { + box-shadow: 0 0 3px $shadow-d1; + padding: ($baseline*1.5); + background-color: $white; + } + + input[type="file"] { + @extend .t-copy-sub2; + } + + .status-upload { + height: 30px; + margin-top: $baseline; + + .wrapper-progress { + box-shadow: inset 0 0 3px $shadow-d1; + display: block; + border-radius: ($baseline*0.75); + background-color: $gray-l5; + padding: 1px 8px 2px 8px; + height: 25px; + + progress { + display: inline-block; + vertical-align: middle; + width: 100%; + border: none; + border-radius: ($baseline*0.75); + background-color: $gray-l5; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: ($baseline*0.75); + } + + &::-webkit-progress-value { + background-color: $pink; + border-radius: ($baseline*0.75); + } + + &::-moz-progress-bar { + background-color: $pink; + border-radius: ($baseline*0.75); + } + + } + + } + + .message-status { + @include border-top-radius(2px); + @include box-sizing(border-box); + @include font-size(14); + display: none; + border-bottom: 2px solid $yellow; + margin: 0 0 20px 0; + padding: 10px 20px; + font-weight: 500; + background: $paleYellow; + + .text { + display: inline-block; + } + + &.error { + border-color: $red-d2; + background: $red-l1; + color: $white; + } + + &.confirm { + border-color: $green-d2; + background: $green-l1; + color: $white; + } + + &.is-shown { + display: block; + } + } + } + + .actions { + padding: ($baseline*0.75) $baseline ($baseline/2) $baseline; + + + + .action-item { + @extend .t-action4; + display: inline-block; + margin-right: ($baseline*0.75); + + &:last-child { + margin-right: 0; + } + } + + .action-primary { + @include blue-button(); + @include font-size(12); // needed due to bad button mixins for now + border-color: $blue-d1; + color: $white; + } + + a { + color: $blue; + + &:hover { + color: $blue-s2; + } + } + + } + + } + + } + + } + + // ==================== + + // js enabled + .js { + + // dialog set-up + .wrapper-dialog { + visibility: hidden; + pointer-events: none; + + .dialog { + opacity: 0; + } + } + + // dialog showing/hiding + &.dialog-is-shown { + + .wrapper-dialog { + -webkit-filter: blur(2px) grayscale(25%); + filter: blur(2px) grayscale(25%); + } + + .wrapper-dialog.is-shown { + visibility: visible; + pointer-events: auto; + + .dialog { + opacity: 1.0; + } + } + } + + } +} diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 28349b54362a..5b79b7f2655f 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -4,7 +4,7 @@ <%! from django.utils.translation import ugettext as _ %> <%block name="title">${_("Textbooks")} -<%block name="bodyclass">is-signedin course textbooks +<%block name="bodyclass">is-signedin course textbooks file-upload-dialog <%block name="header_extras"> % for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]: diff --git a/common/test/data/uploads/test b/common/test/data/uploads/test index 0424951e344c..588e9fb1254e 100644 --- a/common/test/data/uploads/test +++ b/common/test/data/uploads/test @@ -1 +1 @@ -This is an arbitrary file for testing uploads +This is an arbitrary file for testing uploads \ No newline at end of file