From 138b3ba285636302bb513ad9e5b2d0a643e96889 Mon Sep 17 00:00:00 2001 From: Ben Margolis Date: Sun, 15 Jun 2014 22:35:01 -0700 Subject: [PATCH 01/62] first pass of multi-uploader --- .../wagtailadmin/scss/components/forms.scss | 1 + wagtail/wagtailimages/forms.py | 9 + .../static/wagtailimages/js/add-multiple.js | 40 + .../js/vendor/jquery.fileupload.js | 1426 +++++++++++++++++ .../js/vendor/jquery.iframe-transport.js | 214 +++ .../templates/wagtailimages/multiple/add.html | 39 + .../wagtailimages/multiple/confirmation.json | 4 + .../wagtailimages/multiple/edit.html | 18 + wagtail/wagtailimages/urls.py | 6 +- wagtail/wagtailimages/views/multiple.py | 87 + 10 files changed, 1843 insertions(+), 1 deletion(-) create mode 100644 wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js create mode 100644 wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.fileupload.js create mode 100644 wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.iframe-transport.js create mode 100644 wagtail/wagtailimages/templates/wagtailimages/multiple/add.html create mode 100644 wagtail/wagtailimages/templates/wagtailimages/multiple/confirmation.json create mode 100644 wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html create mode 100644 wagtail/wagtailimages/views/multiple.py diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss index 8a0d4f977..dc5ead21f 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss @@ -295,6 +295,7 @@ button.icon{ > li{ position:relative; + overflow:hidden; background-color:white; padding:1em 1.5em; margin-bottom:1em; diff --git a/wagtail/wagtailimages/forms.py b/wagtail/wagtailimages/forms.py index 5c38efc32..248cc1db1 100644 --- a/wagtail/wagtailimages/forms.py +++ b/wagtail/wagtailimages/forms.py @@ -14,6 +14,15 @@ def get_image_form(): widgets={'file': forms.FileInput()}) +def get_image_form_for_multi(): + return modelform_factory( + get_image_model(), + # set the 'file' widget to a FileInput rather than the default ClearableFileInput + # so that when editing, we don't get the 'currently: ...' banner which is + # a bit pointless here + exclude=('file',)) + + class ImageInsertionForm(forms.Form): """ Form for selecting parameters of the image (e.g. format) prior to insertion diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js new file mode 100644 index 000000000..0b83c338f --- /dev/null +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -0,0 +1,40 @@ +$(function () { + + function process_result(data) { + var result = $.parseJSON(data); + if (result.success) { + $('li#image-'+result.image_id).slideUp(function() { $(this).remove(); }); + } + } + + $('#fileupload').fileupload({ + dataType: 'html', + sequentialUploads: true, + done: function (e, data) { + var im_li = $(data.result); + + im_li.find('form').each(function() { + + var jform = $(this); + + jform.submit(function(event) { //convert save to an ajax call + event.preventDefault(); + $.post(this.action, $(this).serialize(), process_result); + }); + + jform.find('a').each(function(){ //convert delete to an ajax call + $(this).click(function(event) { + event.preventDefault(); + $.post(this.href, jform.serialize(), process_result); + }); + + }); + + jform.find('#id_'+ im_li.attr('id') +'-tags').tagit(window.tagit_opts); + }); + + im_li + $("#image-forms").append(im_li); + } + }); +}); \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.fileupload.js b/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.fileupload.js new file mode 100644 index 000000000..0803592d6 --- /dev/null +++ b/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.fileupload.js @@ -0,0 +1,1426 @@ +/* + * jQuery File Upload Plugin 5.40.1 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/* jshint nomen:false */ +/* global define, window, document, location, Blob, FormData */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery.ui.widget' + ], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // Detect file input support, based on + // http://viljamis.com/blog/2012/file-upload-support-on-mobile/ + $.support.fileInput = !(new RegExp( + // Handle devices which give false positives for the feature detection: + '(Android (1\\.[0156]|2\\.[01]))' + + '|(Windows Phone (OS 7|8\\.0))|(XBLWP)|(ZuneWP)|(WPDesktop)' + + '|(w(eb)?OSBrowser)|(webOS)' + + '|(Kindle/(1\\.0|2\\.[05]|3\\.0))' + ).test(window.navigator.userAgent) || + // Feature detection for all other devices: + $('').prop('disabled')); + + // The FileReader API is not actually used, but works as feature detection, + // as some Safari versions (5?) support XHR file uploads via the FormData API, + // but not non-multipart XHR file uploads. + // window.XMLHttpRequestUpload is not available on IE10, so we check for + // window.ProgressEvent instead to detect XHR2 file upload capability: + $.support.xhrFileUpload = !!(window.ProgressEvent && window.FileReader); + $.support.xhrFormDataFileUpload = !!window.FormData; + + // Detect support for Blob slicing (required for chunked uploads): + $.support.blobSlice = window.Blob && (Blob.prototype.slice || + Blob.prototype.webkitSlice || Blob.prototype.mozSlice); + + // The fileupload widget listens for change events on file input fields defined + // via fileInput setting and paste or drop events of the given dropZone. + // In addition to the default jQuery Widget methods, the fileupload widget + // exposes the "add" and "send" methods, to add or directly send files using + // the fileupload API. + // By default, files added via file input selection, paste, drag & drop or + // "add" method are uploaded immediately, but it is possible to override + // the "add" callback option to queue file uploads. + $.widget('blueimp.fileupload', { + + options: { + // The drop target element(s), by the default the complete document. + // Set to null to disable drag & drop support: + dropZone: $(document), + // The paste target element(s), by the default the complete document. + // Set to null to disable paste support: + pasteZone: $(document), + // The file input field(s), that are listened to for change events. + // If undefined, it is set to the file input fields inside + // of the widget element on plugin initialization. + // Set to null to disable the change listener. + fileInput: undefined, + // By default, the file input field is replaced with a clone after + // each input field change event. This is required for iframe transport + // queues and allows change events to be fired for the same file + // selection, but can be disabled by setting the following option to false: + replaceFileInput: true, + // The parameter name for the file form data (the request argument name). + // If undefined or empty, the name property of the file input field is + // used, or "files[]" if the file input name property is also empty, + // can be a string or an array of strings: + paramName: undefined, + // By default, each file of a selection is uploaded using an individual + // request for XHR type uploads. Set to false to upload file + // selections in one request each: + singleFileUploads: true, + // To limit the number of files uploaded with one XHR request, + // set the following option to an integer greater than 0: + limitMultiFileUploads: undefined, + // The following option limits the number of files uploaded with one + // XHR request to keep the request size under or equal to the defined + // limit in bytes: + limitMultiFileUploadSize: undefined, + // Multipart file uploads add a number of bytes to each uploaded file, + // therefore the following option adds an overhead for each file used + // in the limitMultiFileUploadSize configuration: + limitMultiFileUploadSizeOverhead: 512, + // Set the following option to true to issue all file upload requests + // in a sequential order: + sequentialUploads: false, + // To limit the number of concurrent uploads, + // set the following option to an integer greater than 0: + limitConcurrentUploads: undefined, + // Set the following option to true to force iframe transport uploads: + forceIframeTransport: false, + // Set the following option to the location of a redirect url on the + // origin server, for cross-domain iframe transport uploads: + redirect: undefined, + // The parameter name for the redirect url, sent as part of the form + // data and set to 'redirect' if this option is empty: + redirectParamName: undefined, + // Set the following option to the location of a postMessage window, + // to enable postMessage transport uploads: + postMessage: undefined, + // By default, XHR file uploads are sent as multipart/form-data. + // The iframe transport is always using multipart/form-data. + // Set to false to enable non-multipart XHR uploads: + multipart: true, + // To upload large files in smaller chunks, set the following option + // to a preferred maximum chunk size. If set to 0, null or undefined, + // or the browser does not support the required Blob API, files will + // be uploaded as a whole. + maxChunkSize: undefined, + // When a non-multipart upload or a chunked multipart upload has been + // aborted, this option can be used to resume the upload by setting + // it to the size of the already uploaded bytes. This option is most + // useful when modifying the options object inside of the "add" or + // "send" callbacks, as the options are cloned for each file upload. + uploadedBytes: undefined, + // By default, failed (abort or error) file uploads are removed from the + // global progress calculation. Set the following option to false to + // prevent recalculating the global progress data: + recalculateProgress: true, + // Interval in milliseconds to calculate and trigger progress events: + progressInterval: 100, + // Interval in milliseconds to calculate progress bitrate: + bitrateInterval: 500, + // By default, uploads are started automatically when adding files: + autoUpload: true, + + // Error and info messages: + messages: { + uploadedBytes: 'Uploaded bytes exceed file size' + }, + + // Translation function, gets the message key to be translated + // and an object with context specific data as arguments: + i18n: function (message, context) { + message = this.messages[message] || message.toString(); + if (context) { + $.each(context, function (key, value) { + message = message.replace('{' + key + '}', value); + }); + } + return message; + }, + + // Additional form data to be sent along with the file uploads can be set + // using this option, which accepts an array of objects with name and + // value properties, a function returning such an array, a FormData + // object (for XHR file uploads), or a simple object. + // The form of the first fileInput is given as parameter to the function: + formData: function (form) { + return form.serializeArray(); + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop, paste or add API call). + // If the singleFileUploads option is enabled, this callback will be + // called once for each file in the selection for XHR file uploads, else + // once for each file selection. + // + // The upload starts when the submit method is invoked on the data parameter. + // The data object contains a files property holding the added files + // and allows you to override plugin options as well as define ajax settings. + // + // Listeners for this callback can also be bound the following way: + // .bind('fileuploadadd', func); + // + // data.submit() returns a Promise object and allows to attach additional + // handlers using jQuery's Deferred callbacks: + // data.submit().done(func).fail(func).always(func); + add: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + if (data.autoUpload || (data.autoUpload !== false && + $(this).fileupload('option', 'autoUpload'))) { + data.process().done(function () { + data.submit(); + }); + } + }, + + // Other callbacks: + + // Callback for the submit event of each file upload: + // submit: function (e, data) {}, // .bind('fileuploadsubmit', func); + + // Callback for the start of each file upload request: + // send: function (e, data) {}, // .bind('fileuploadsend', func); + + // Callback for successful uploads: + // done: function (e, data) {}, // .bind('fileuploaddone', func); + + // Callback for failed (abort or error) uploads: + // fail: function (e, data) {}, // .bind('fileuploadfail', func); + + // Callback for completed (success, abort or error) requests: + // always: function (e, data) {}, // .bind('fileuploadalways', func); + + // Callback for upload progress events: + // progress: function (e, data) {}, // .bind('fileuploadprogress', func); + + // Callback for global upload progress events: + // progressall: function (e, data) {}, // .bind('fileuploadprogressall', func); + + // Callback for uploads start, equivalent to the global ajaxStart event: + // start: function (e) {}, // .bind('fileuploadstart', func); + + // Callback for uploads stop, equivalent to the global ajaxStop event: + // stop: function (e) {}, // .bind('fileuploadstop', func); + + // Callback for change events of the fileInput(s): + // change: function (e, data) {}, // .bind('fileuploadchange', func); + + // Callback for paste events to the pasteZone(s): + // paste: function (e, data) {}, // .bind('fileuploadpaste', func); + + // Callback for drop events of the dropZone(s): + // drop: function (e, data) {}, // .bind('fileuploaddrop', func); + + // Callback for dragover events of the dropZone(s): + // dragover: function (e) {}, // .bind('fileuploaddragover', func); + + // Callback for the start of each chunk upload request: + // chunksend: function (e, data) {}, // .bind('fileuploadchunksend', func); + + // Callback for successful chunk uploads: + // chunkdone: function (e, data) {}, // .bind('fileuploadchunkdone', func); + + // Callback for failed (abort or error) chunk uploads: + // chunkfail: function (e, data) {}, // .bind('fileuploadchunkfail', func); + + // Callback for completed (success, abort or error) chunk upload requests: + // chunkalways: function (e, data) {}, // .bind('fileuploadchunkalways', func); + + // The plugin options are used as settings object for the ajax calls. + // The following are jQuery ajax settings required for the file uploads: + processData: false, + contentType: false, + cache: false + }, + + // A list of options that require reinitializing event listeners and/or + // special initialization code: + _specialOptions: [ + 'fileInput', + 'dropZone', + 'pasteZone', + 'multipart', + 'forceIframeTransport' + ], + + _blobSlice: $.support.blobSlice && function () { + var slice = this.slice || this.webkitSlice || this.mozSlice; + return slice.apply(this, arguments); + }, + + _BitrateTimer: function () { + this.timestamp = ((Date.now) ? Date.now() : (new Date()).getTime()); + this.loaded = 0; + this.bitrate = 0; + this.getBitrate = function (now, loaded, interval) { + var timeDiff = now - this.timestamp; + if (!this.bitrate || !interval || timeDiff > interval) { + this.bitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; + this.loaded = loaded; + this.timestamp = now; + } + return this.bitrate; + }; + }, + + _isXHRUpload: function (options) { + return !options.forceIframeTransport && + ((!options.multipart && $.support.xhrFileUpload) || + $.support.xhrFormDataFileUpload); + }, + + _getFormData: function (options) { + var formData; + if ($.type(options.formData) === 'function') { + return options.formData(options.form); + } + if ($.isArray(options.formData)) { + return options.formData; + } + if ($.type(options.formData) === 'object') { + formData = []; + $.each(options.formData, function (name, value) { + formData.push({name: name, value: value}); + }); + return formData; + } + return []; + }, + + _getTotal: function (files) { + var total = 0; + $.each(files, function (index, file) { + total += file.size || 1; + }); + return total; + }, + + _initProgressObject: function (obj) { + var progress = { + loaded: 0, + total: 0, + bitrate: 0 + }; + if (obj._progress) { + $.extend(obj._progress, progress); + } else { + obj._progress = progress; + } + }, + + _initResponseObject: function (obj) { + var prop; + if (obj._response) { + for (prop in obj._response) { + if (obj._response.hasOwnProperty(prop)) { + delete obj._response[prop]; + } + } + } else { + obj._response = {}; + } + }, + + _onProgress: function (e, data) { + if (e.lengthComputable) { + var now = ((Date.now) ? Date.now() : (new Date()).getTime()), + loaded; + if (data._time && data.progressInterval && + (now - data._time < data.progressInterval) && + e.loaded !== e.total) { + return; + } + data._time = now; + loaded = Math.floor( + e.loaded / e.total * (data.chunkSize || data._progress.total) + ) + (data.uploadedBytes || 0); + // Add the difference from the previously loaded state + // to the global loaded counter: + this._progress.loaded += (loaded - data._progress.loaded); + this._progress.bitrate = this._bitrateTimer.getBitrate( + now, + this._progress.loaded, + data.bitrateInterval + ); + data._progress.loaded = data.loaded = loaded; + data._progress.bitrate = data.bitrate = data._bitrateTimer.getBitrate( + now, + loaded, + data.bitrateInterval + ); + // Trigger a custom progress event with a total data property set + // to the file size(s) of the current upload and a loaded data + // property calculated accordingly: + this._trigger( + 'progress', + $.Event('progress', {delegatedEvent: e}), + data + ); + // Trigger a global progress event for all current file uploads, + // including ajax calls queued for sequential file uploads: + this._trigger( + 'progressall', + $.Event('progressall', {delegatedEvent: e}), + this._progress + ); + } + }, + + _initProgressListener: function (options) { + var that = this, + xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); + // Accesss to the native XHR object is required to add event listeners + // for the upload progress event: + if (xhr.upload) { + $(xhr.upload).bind('progress', function (e) { + var oe = e.originalEvent; + // Make sure the progress event properties get copied over: + e.lengthComputable = oe.lengthComputable; + e.loaded = oe.loaded; + e.total = oe.total; + that._onProgress(e, options); + }); + options.xhr = function () { + return xhr; + }; + } + }, + + _isInstanceOf: function (type, obj) { + // Cross-frame instanceof check + return Object.prototype.toString.call(obj) === '[object ' + type + ']'; + }, + + _initXHRData: function (options) { + var that = this, + formData, + file = options.files[0], + // Ignore non-multipart setting if not supported: + multipart = options.multipart || !$.support.xhrFileUpload, + paramName = $.type(options.paramName) === 'array' ? + options.paramName[0] : options.paramName; + options.headers = $.extend({}, options.headers); + if (options.contentRange) { + options.headers['Content-Range'] = options.contentRange; + } + if (!multipart || options.blob || !this._isInstanceOf('File', file)) { + options.headers['Content-Disposition'] = 'attachment; filename="' + + encodeURI(file.name) + '"'; + } + if (!multipart) { + options.contentType = file.type || 'application/octet-stream'; + options.data = options.blob || file; + } else if ($.support.xhrFormDataFileUpload) { + if (options.postMessage) { + // window.postMessage does not allow sending FormData + // objects, so we just add the File/Blob objects to + // the formData array and let the postMessage window + // create the FormData object out of this array: + formData = this._getFormData(options); + if (options.blob) { + formData.push({ + name: paramName, + value: options.blob + }); + } else { + $.each(options.files, function (index, file) { + formData.push({ + name: ($.type(options.paramName) === 'array' && + options.paramName[index]) || paramName, + value: file + }); + }); + } + } else { + if (that._isInstanceOf('FormData', options.formData)) { + formData = options.formData; + } else { + formData = new FormData(); + $.each(this._getFormData(options), function (index, field) { + formData.append(field.name, field.value); + }); + } + if (options.blob) { + formData.append(paramName, options.blob, file.name); + } else { + $.each(options.files, function (index, file) { + // This check allows the tests to run with + // dummy objects: + if (that._isInstanceOf('File', file) || + that._isInstanceOf('Blob', file)) { + formData.append( + ($.type(options.paramName) === 'array' && + options.paramName[index]) || paramName, + file, + file.uploadName || file.name + ); + } + }); + } + } + options.data = formData; + } + // Blob reference is not needed anymore, free memory: + options.blob = null; + }, + + _initIframeSettings: function (options) { + var targetHost = $('').prop('href', options.url).prop('host'); + // Setting the dataType to iframe enables the iframe transport: + options.dataType = 'iframe ' + (options.dataType || ''); + // The iframe transport accepts a serialized array as form data: + options.formData = this._getFormData(options); + // Add redirect url to form data on cross-domain uploads: + if (options.redirect && targetHost && targetHost !== location.host) { + options.formData.push({ + name: options.redirectParamName || 'redirect', + value: options.redirect + }); + } + }, + + _initDataSettings: function (options) { + if (this._isXHRUpload(options)) { + if (!this._chunkedUpload(options, true)) { + if (!options.data) { + this._initXHRData(options); + } + this._initProgressListener(options); + } + if (options.postMessage) { + // Setting the dataType to postmessage enables the + // postMessage transport: + options.dataType = 'postmessage ' + (options.dataType || ''); + } + } else { + this._initIframeSettings(options); + } + }, + + _getParamName: function (options) { + var fileInput = $(options.fileInput), + paramName = options.paramName; + if (!paramName) { + paramName = []; + fileInput.each(function () { + var input = $(this), + name = input.prop('name') || 'files[]', + i = (input.prop('files') || [1]).length; + while (i) { + paramName.push(name); + i -= 1; + } + }); + if (!paramName.length) { + paramName = [fileInput.prop('name') || 'files[]']; + } + } else if (!$.isArray(paramName)) { + paramName = [paramName]; + } + return paramName; + }, + + _initFormSettings: function (options) { + // Retrieve missing options from the input field and the + // associated form, if available: + if (!options.form || !options.form.length) { + options.form = $(options.fileInput.prop('form')); + // If the given file input doesn't have an associated form, + // use the default widget file input's form: + if (!options.form.length) { + options.form = $(this.options.fileInput.prop('form')); + } + } + options.paramName = this._getParamName(options); + if (!options.url) { + options.url = options.form.prop('action') || location.href; + } + // The HTTP request method must be "POST" or "PUT": + options.type = (options.type || + ($.type(options.form.prop('method')) === 'string' && + options.form.prop('method')) || '' + ).toUpperCase(); + if (options.type !== 'POST' && options.type !== 'PUT' && + options.type !== 'PATCH') { + options.type = 'POST'; + } + if (!options.formAcceptCharset) { + options.formAcceptCharset = options.form.attr('accept-charset'); + } + }, + + _getAJAXSettings: function (data) { + var options = $.extend({}, this.options, data); + this._initFormSettings(options); + this._initDataSettings(options); + return options; + }, + + // jQuery 1.6 doesn't provide .state(), + // while jQuery 1.8+ removed .isRejected() and .isResolved(): + _getDeferredState: function (deferred) { + if (deferred.state) { + return deferred.state(); + } + if (deferred.isResolved()) { + return 'resolved'; + } + if (deferred.isRejected()) { + return 'rejected'; + } + return 'pending'; + }, + + // Maps jqXHR callbacks to the equivalent + // methods of the given Promise object: + _enhancePromise: function (promise) { + promise.success = promise.done; + promise.error = promise.fail; + promise.complete = promise.always; + return promise; + }, + + // Creates and returns a Promise object enhanced with + // the jqXHR methods abort, success, error and complete: + _getXHRPromise: function (resolveOrReject, context, args) { + var dfd = $.Deferred(), + promise = dfd.promise(); + context = context || this.options.context || promise; + if (resolveOrReject === true) { + dfd.resolveWith(context, args); + } else if (resolveOrReject === false) { + dfd.rejectWith(context, args); + } + promise.abort = dfd.promise; + return this._enhancePromise(promise); + }, + + // Adds convenience methods to the data callback argument: + _addConvenienceMethods: function (e, data) { + var that = this, + getPromise = function (args) { + return $.Deferred().resolveWith(that, args).promise(); + }; + data.process = function (resolveFunc, rejectFunc) { + if (resolveFunc || rejectFunc) { + data._processQueue = this._processQueue = + (this._processQueue || getPromise([this])).pipe( + function () { + if (data.errorThrown) { + return $.Deferred() + .rejectWith(that, [data]).promise(); + } + return getPromise(arguments); + } + ).pipe(resolveFunc, rejectFunc); + } + return this._processQueue || getPromise([this]); + }; + data.submit = function () { + if (this.state() !== 'pending') { + data.jqXHR = this.jqXHR = + (that._trigger( + 'submit', + $.Event('submit', {delegatedEvent: e}), + this + ) !== false) && that._onSend(e, this); + } + return this.jqXHR || that._getXHRPromise(); + }; + data.abort = function () { + if (this.jqXHR) { + return this.jqXHR.abort(); + } + this.errorThrown = 'abort'; + that._trigger('fail', null, this); + return that._getXHRPromise(false); + }; + data.state = function () { + if (this.jqXHR) { + return that._getDeferredState(this.jqXHR); + } + if (this._processQueue) { + return that._getDeferredState(this._processQueue); + } + }; + data.processing = function () { + return !this.jqXHR && this._processQueue && that + ._getDeferredState(this._processQueue) === 'pending'; + }; + data.progress = function () { + return this._progress; + }; + data.response = function () { + return this._response; + }; + }, + + // Parses the Range header from the server response + // and returns the uploaded bytes: + _getUploadedBytes: function (jqXHR) { + var range = jqXHR.getResponseHeader('Range'), + parts = range && range.split('-'), + upperBytesPos = parts && parts.length > 1 && + parseInt(parts[1], 10); + return upperBytesPos && upperBytesPos + 1; + }, + + // Uploads a file in multiple, sequential requests + // by splitting the file up in multiple blob chunks. + // If the second parameter is true, only tests if the file + // should be uploaded in chunks, but does not invoke any + // upload requests: + _chunkedUpload: function (options, testOnly) { + options.uploadedBytes = options.uploadedBytes || 0; + var that = this, + file = options.files[0], + fs = file.size, + ub = options.uploadedBytes, + mcs = options.maxChunkSize || fs, + slice = this._blobSlice, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + upload; + if (!(this._isXHRUpload(options) && slice && (ub || mcs < fs)) || + options.data) { + return false; + } + if (testOnly) { + return true; + } + if (ub >= fs) { + file.error = options.i18n('uploadedBytes'); + return this._getXHRPromise( + false, + options.context, + [null, 'error', file.error] + ); + } + // The chunk upload method: + upload = function () { + // Clone the options object for each chunk upload: + var o = $.extend({}, options), + currentLoaded = o._progress.loaded; + o.blob = slice.call( + file, + ub, + ub + mcs, + file.type + ); + // Store the current chunk size, as the blob itself + // will be dereferenced after data processing: + o.chunkSize = o.blob.size; + // Expose the chunk bytes position range: + o.contentRange = 'bytes ' + ub + '-' + + (ub + o.chunkSize - 1) + '/' + fs; + // Process the upload data (the blob and potential form data): + that._initXHRData(o); + // Add progress listeners for this chunk upload: + that._initProgressListener(o); + jqXHR = ((that._trigger('chunksend', null, o) !== false && $.ajax(o)) || + that._getXHRPromise(false, o.context)) + .done(function (result, textStatus, jqXHR) { + ub = that._getUploadedBytes(jqXHR) || + (ub + o.chunkSize); + // Create a progress event if no final progress event + // with loaded equaling total has been triggered + // for this chunk: + if (currentLoaded + o.chunkSize - o._progress.loaded) { + that._onProgress($.Event('progress', { + lengthComputable: true, + loaded: ub - o.uploadedBytes, + total: ub - o.uploadedBytes + }), o); + } + options.uploadedBytes = o.uploadedBytes = ub; + o.result = result; + o.textStatus = textStatus; + o.jqXHR = jqXHR; + that._trigger('chunkdone', null, o); + that._trigger('chunkalways', null, o); + if (ub < fs) { + // File upload not yet complete, + // continue with the next chunk: + upload(); + } else { + dfd.resolveWith( + o.context, + [result, textStatus, jqXHR] + ); + } + }) + .fail(function (jqXHR, textStatus, errorThrown) { + o.jqXHR = jqXHR; + o.textStatus = textStatus; + o.errorThrown = errorThrown; + that._trigger('chunkfail', null, o); + that._trigger('chunkalways', null, o); + dfd.rejectWith( + o.context, + [jqXHR, textStatus, errorThrown] + ); + }); + }; + this._enhancePromise(promise); + promise.abort = function () { + return jqXHR.abort(); + }; + upload(); + return promise; + }, + + _beforeSend: function (e, data) { + if (this._active === 0) { + // the start callback is triggered when an upload starts + // and no other uploads are currently running, + // equivalent to the global ajaxStart event: + this._trigger('start'); + // Set timer for global bitrate progress calculation: + this._bitrateTimer = new this._BitrateTimer(); + // Reset the global progress values: + this._progress.loaded = this._progress.total = 0; + this._progress.bitrate = 0; + } + // Make sure the container objects for the .response() and + // .progress() methods on the data object are available + // and reset to their initial state: + this._initResponseObject(data); + this._initProgressObject(data); + data._progress.loaded = data.loaded = data.uploadedBytes || 0; + data._progress.total = data.total = this._getTotal(data.files) || 1; + data._progress.bitrate = data.bitrate = 0; + this._active += 1; + // Initialize the global progress values: + this._progress.loaded += data.loaded; + this._progress.total += data.total; + }, + + _onDone: function (result, textStatus, jqXHR, options) { + var total = options._progress.total, + response = options._response; + if (options._progress.loaded < total) { + // Create a progress event if no final progress event + // with loaded equaling total has been triggered: + this._onProgress($.Event('progress', { + lengthComputable: true, + loaded: total, + total: total + }), options); + } + response.result = options.result = result; + response.textStatus = options.textStatus = textStatus; + response.jqXHR = options.jqXHR = jqXHR; + this._trigger('done', null, options); + }, + + _onFail: function (jqXHR, textStatus, errorThrown, options) { + var response = options._response; + if (options.recalculateProgress) { + // Remove the failed (error or abort) file upload from + // the global progress calculation: + this._progress.loaded -= options._progress.loaded; + this._progress.total -= options._progress.total; + } + response.jqXHR = options.jqXHR = jqXHR; + response.textStatus = options.textStatus = textStatus; + response.errorThrown = options.errorThrown = errorThrown; + this._trigger('fail', null, options); + }, + + _onAlways: function (jqXHRorResult, textStatus, jqXHRorError, options) { + // jqXHRorResult, textStatus and jqXHRorError are added to the + // options object via done and fail callbacks + this._trigger('always', null, options); + }, + + _onSend: function (e, data) { + if (!data.submit) { + this._addConvenienceMethods(e, data); + } + var that = this, + jqXHR, + aborted, + slot, + pipe, + options = that._getAJAXSettings(data), + send = function () { + that._sending += 1; + // Set timer for bitrate progress calculation: + options._bitrateTimer = new that._BitrateTimer(); + jqXHR = jqXHR || ( + ((aborted || that._trigger( + 'send', + $.Event('send', {delegatedEvent: e}), + options + ) === false) && + that._getXHRPromise(false, options.context, aborted)) || + that._chunkedUpload(options) || $.ajax(options) + ).done(function (result, textStatus, jqXHR) { + that._onDone(result, textStatus, jqXHR, options); + }).fail(function (jqXHR, textStatus, errorThrown) { + that._onFail(jqXHR, textStatus, errorThrown, options); + }).always(function (jqXHRorResult, textStatus, jqXHRorError) { + that._onAlways( + jqXHRorResult, + textStatus, + jqXHRorError, + options + ); + that._sending -= 1; + that._active -= 1; + if (options.limitConcurrentUploads && + options.limitConcurrentUploads > that._sending) { + // Start the next queued upload, + // that has not been aborted: + var nextSlot = that._slots.shift(); + while (nextSlot) { + if (that._getDeferredState(nextSlot) === 'pending') { + nextSlot.resolve(); + break; + } + nextSlot = that._slots.shift(); + } + } + if (that._active === 0) { + // The stop callback is triggered when all uploads have + // been completed, equivalent to the global ajaxStop event: + that._trigger('stop'); + } + }); + return jqXHR; + }; + this._beforeSend(e, options); + if (this.options.sequentialUploads || + (this.options.limitConcurrentUploads && + this.options.limitConcurrentUploads <= this._sending)) { + if (this.options.limitConcurrentUploads > 1) { + slot = $.Deferred(); + this._slots.push(slot); + pipe = slot.pipe(send); + } else { + this._sequence = this._sequence.pipe(send, send); + pipe = this._sequence; + } + // Return the piped Promise object, enhanced with an abort method, + // which is delegated to the jqXHR object of the current upload, + // and jqXHR callbacks mapped to the equivalent Promise methods: + pipe.abort = function () { + aborted = [undefined, 'abort', 'abort']; + if (!jqXHR) { + if (slot) { + slot.rejectWith(options.context, aborted); + } + return send(); + } + return jqXHR.abort(); + }; + return this._enhancePromise(pipe); + } + return send(); + }, + + _onAdd: function (e, data) { + var that = this, + result = true, + options = $.extend({}, this.options, data), + files = data.files, + filesLength = files.length, + limit = options.limitMultiFileUploads, + limitSize = options.limitMultiFileUploadSize, + overhead = options.limitMultiFileUploadSizeOverhead, + batchSize = 0, + paramName = this._getParamName(options), + paramNameSet, + paramNameSlice, + fileSet, + i, + j = 0; + if (limitSize && (!filesLength || files[0].size === undefined)) { + limitSize = undefined; + } + if (!(options.singleFileUploads || limit || limitSize) || + !this._isXHRUpload(options)) { + fileSet = [files]; + paramNameSet = [paramName]; + } else if (!(options.singleFileUploads || limitSize) && limit) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < filesLength; i += limit) { + fileSet.push(files.slice(i, i + limit)); + paramNameSlice = paramName.slice(i, i + limit); + if (!paramNameSlice.length) { + paramNameSlice = paramName; + } + paramNameSet.push(paramNameSlice); + } + } else if (!options.singleFileUploads && limitSize) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < filesLength; i = i + 1) { + batchSize += files[i].size + overhead; + if (i + 1 === filesLength || + ((batchSize + files[i + 1].size + overhead) > limitSize) || + (limit && i + 1 - j >= limit)) { + fileSet.push(files.slice(j, i + 1)); + paramNameSlice = paramName.slice(j, i + 1); + if (!paramNameSlice.length) { + paramNameSlice = paramName; + } + paramNameSet.push(paramNameSlice); + j = i + 1; + batchSize = 0; + } + } + } else { + paramNameSet = paramName; + } + data.originalFiles = files; + $.each(fileSet || files, function (index, element) { + var newData = $.extend({}, data); + newData.files = fileSet ? element : [element]; + newData.paramName = paramNameSet[index]; + that._initResponseObject(newData); + that._initProgressObject(newData); + that._addConvenienceMethods(e, newData); + result = that._trigger( + 'add', + $.Event('add', {delegatedEvent: e}), + newData + ); + return result; + }); + return result; + }, + + _replaceFileInput: function (input) { + var inputClone = input.clone(true); + $('
').append(inputClone)[0].reset(); + // Detaching allows to insert the fileInput on another form + // without loosing the file input value: + input.after(inputClone).detach(); + // Avoid memory leaks with the detached file input: + $.cleanData(input.unbind('remove')); + // Replace the original file input element in the fileInput + // elements set with the clone, which has been copied including + // event handlers: + this.options.fileInput = this.options.fileInput.map(function (i, el) { + if (el === input[0]) { + return inputClone[0]; + } + return el; + }); + // If the widget has been initialized on the file input itself, + // override this.element with the file input clone: + if (input[0] === this.element[0]) { + this.element = inputClone; + } + }, + + _handleFileTreeEntry: function (entry, path) { + var that = this, + dfd = $.Deferred(), + errorHandler = function (e) { + if (e && !e.entry) { + e.entry = entry; + } + // Since $.when returns immediately if one + // Deferred is rejected, we use resolve instead. + // This allows valid files and invalid items + // to be returned together in one set: + dfd.resolve([e]); + }, + dirReader; + path = path || ''; + if (entry.isFile) { + if (entry._file) { + // Workaround for Chrome bug #149735 + entry._file.relativePath = path; + dfd.resolve(entry._file); + } else { + entry.file(function (file) { + file.relativePath = path; + dfd.resolve(file); + }, errorHandler); + } + } else if (entry.isDirectory) { + dirReader = entry.createReader(); + dirReader.readEntries(function (entries) { + that._handleFileTreeEntries( + entries, + path + entry.name + '/' + ).done(function (files) { + dfd.resolve(files); + }).fail(errorHandler); + }, errorHandler); + } else { + // Return an empy list for file system items + // other than files or directories: + dfd.resolve([]); + } + return dfd.promise(); + }, + + _handleFileTreeEntries: function (entries, path) { + var that = this; + return $.when.apply( + $, + $.map(entries, function (entry) { + return that._handleFileTreeEntry(entry, path); + }) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _getDroppedFiles: function (dataTransfer) { + dataTransfer = dataTransfer || {}; + var items = dataTransfer.items; + if (items && items.length && (items[0].webkitGetAsEntry || + items[0].getAsEntry)) { + return this._handleFileTreeEntries( + $.map(items, function (item) { + var entry; + if (item.webkitGetAsEntry) { + entry = item.webkitGetAsEntry(); + if (entry) { + // Workaround for Chrome bug #149735: + entry._file = item.getAsFile(); + } + return entry; + } + return item.getAsEntry(); + }) + ); + } + return $.Deferred().resolve( + $.makeArray(dataTransfer.files) + ).promise(); + }, + + _getSingleFileInputFiles: function (fileInput) { + fileInput = $(fileInput); + var entries = fileInput.prop('webkitEntries') || + fileInput.prop('entries'), + files, + value; + if (entries && entries.length) { + return this._handleFileTreeEntries(entries); + } + files = $.makeArray(fileInput.prop('files')); + if (!files.length) { + value = fileInput.prop('value'); + if (!value) { + return $.Deferred().resolve([]).promise(); + } + // If the files property is not available, the browser does not + // support the File API and we add a pseudo File object with + // the input value as name with path information removed: + files = [{name: value.replace(/^.*\\/, '')}]; + } else if (files[0].name === undefined && files[0].fileName) { + // File normalization for Safari 4 and Firefox 3: + $.each(files, function (index, file) { + file.name = file.fileName; + file.size = file.fileSize; + }); + } + return $.Deferred().resolve(files).promise(); + }, + + _getFileInputFiles: function (fileInput) { + if (!(fileInput instanceof $) || fileInput.length === 1) { + return this._getSingleFileInputFiles(fileInput); + } + return $.when.apply( + $, + $.map(fileInput, this._getSingleFileInputFiles) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _onChange: function (e) { + var that = this, + data = { + fileInput: $(e.target), + form: $(e.target.form) + }; + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + if (that.options.replaceFileInput) { + that._replaceFileInput(data.fileInput); + } + if (that._trigger( + 'change', + $.Event('change', {delegatedEvent: e}), + data + ) !== false) { + that._onAdd(e, data); + } + }); + }, + + _onPaste: function (e) { + var items = e.originalEvent && e.originalEvent.clipboardData && + e.originalEvent.clipboardData.items, + data = {files: []}; + if (items && items.length) { + $.each(items, function (index, item) { + var file = item.getAsFile && item.getAsFile(); + if (file) { + data.files.push(file); + } + }); + if (this._trigger( + 'paste', + $.Event('paste', {delegatedEvent: e}), + data + ) !== false) { + this._onAdd(e, data); + } + } + }, + + _onDrop: function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var that = this, + dataTransfer = e.dataTransfer, + data = {}; + if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { + e.preventDefault(); + this._getDroppedFiles(dataTransfer).always(function (files) { + data.files = files; + if (that._trigger( + 'drop', + $.Event('drop', {delegatedEvent: e}), + data + ) !== false) { + that._onAdd(e, data); + } + }); + } + }, + + _onDragOver: function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var dataTransfer = e.dataTransfer; + if (dataTransfer && $.inArray('Files', dataTransfer.types) !== -1 && + this._trigger( + 'dragover', + $.Event('dragover', {delegatedEvent: e}) + ) !== false) { + e.preventDefault(); + dataTransfer.dropEffect = 'copy'; + } + }, + + _initEventHandlers: function () { + if (this._isXHRUpload(this.options)) { + this._on(this.options.dropZone, { + dragover: this._onDragOver, + drop: this._onDrop + }); + this._on(this.options.pasteZone, { + paste: this._onPaste + }); + } + if ($.support.fileInput) { + this._on(this.options.fileInput, { + change: this._onChange + }); + } + }, + + _destroyEventHandlers: function () { + this._off(this.options.dropZone, 'dragover drop'); + this._off(this.options.pasteZone, 'paste'); + this._off(this.options.fileInput, 'change'); + }, + + _setOption: function (key, value) { + var reinit = $.inArray(key, this._specialOptions) !== -1; + if (reinit) { + this._destroyEventHandlers(); + } + this._super(key, value); + if (reinit) { + this._initSpecialOptions(); + this._initEventHandlers(); + } + }, + + _initSpecialOptions: function () { + var options = this.options; + if (options.fileInput === undefined) { + options.fileInput = this.element.is('input[type="file"]') ? + this.element : this.element.find('input[type="file"]'); + } else if (!(options.fileInput instanceof $)) { + options.fileInput = $(options.fileInput); + } + if (!(options.dropZone instanceof $)) { + options.dropZone = $(options.dropZone); + } + if (!(options.pasteZone instanceof $)) { + options.pasteZone = $(options.pasteZone); + } + }, + + _getRegExp: function (str) { + var parts = str.split('/'), + modifiers = parts.pop(); + parts.shift(); + return new RegExp(parts.join('/'), modifiers); + }, + + _isRegExpOption: function (key, value) { + return key !== 'url' && $.type(value) === 'string' && + /^\/.*\/[igm]{0,3}$/.test(value); + }, + + _initDataAttributes: function () { + var that = this, + options = this.options, + clone = $(this.element[0].cloneNode(false)); + // Initialize options set via HTML5 data-attributes: + $.each( + clone.data(), + function (key, value) { + var dataAttributeName = 'data-' + + // Convert camelCase to hyphen-ated key: + key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + if (clone.attr(dataAttributeName)) { + if (that._isRegExpOption(key, value)) { + value = that._getRegExp(value); + } + options[key] = value; + } + } + ); + }, + + _create: function () { + this._initDataAttributes(); + this._initSpecialOptions(); + this._slots = []; + this._sequence = this._getXHRPromise(true); + this._sending = this._active = 0; + this._initProgressObject(this); + this._initEventHandlers(); + }, + + // This method is exposed to the widget API and allows to query + // the number of active uploads: + active: function () { + return this._active; + }, + + // This method is exposed to the widget API and allows to query + // the widget upload progress. + // It returns an object with loaded, total and bitrate properties + // for the running uploads: + progress: function () { + return this._progress; + }, + + // This method is exposed to the widget API and allows adding files + // using the fileupload API. The data parameter accepts an object which + // must have a files property and can contain additional options: + // .fileupload('add', {files: filesList}); + add: function (data) { + var that = this; + if (!data || this.options.disabled) { + return; + } + if (data.fileInput && !data.files) { + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + that._onAdd(null, data); + }); + } else { + data.files = $.makeArray(data.files); + this._onAdd(null, data); + } + }, + + // This method is exposed to the widget API and allows sending files + // using the fileupload API. The data parameter accepts an object which + // must have a files or fileInput property and can contain additional options: + // .fileupload('send', {files: filesList}); + // The method returns a Promise object for the file upload call. + send: function (data) { + if (data && !this.options.disabled) { + if (data.fileInput && !data.files) { + var that = this, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + aborted; + promise.abort = function () { + aborted = true; + if (jqXHR) { + return jqXHR.abort(); + } + dfd.reject(null, 'abort', 'abort'); + return promise; + }; + this._getFileInputFiles(data.fileInput).always( + function (files) { + if (aborted) { + return; + } + if (!files.length) { + dfd.reject(); + return; + } + data.files = files; + jqXHR = that._onSend(null, data).then( + function (result, textStatus, jqXHR) { + dfd.resolve(result, textStatus, jqXHR); + }, + function (jqXHR, textStatus, errorThrown) { + dfd.reject(jqXHR, textStatus, errorThrown); + } + ); + } + ); + return this._enhancePromise(promise); + } + data.files = $.makeArray(data.files); + if (data.files.length) { + return this._onSend(null, data); + } + } + return this._getXHRPromise(false, data && data.context); + } + + }); + +})); diff --git a/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.iframe-transport.js b/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.iframe-transport.js new file mode 100644 index 000000000..8d64b591b --- /dev/null +++ b/wagtail/wagtailimages/static/wagtailimages/js/vendor/jquery.iframe-transport.js @@ -0,0 +1,214 @@ +/* + * jQuery Iframe Transport Plugin 1.8.2 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/* global define, window, document */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // Helper variable to create unique names for the transport iframes: + var counter = 0; + + // The iframe transport accepts four additional options: + // options.fileInput: a jQuery collection of file input fields + // options.paramName: the parameter name for the file form data, + // overrides the name property of the file input field(s), + // can be a string or an array of strings. + // options.formData: an array of objects with name and value properties, + // equivalent to the return data of .serializeArray(), e.g.: + // [{name: 'a', value: 1}, {name: 'b', value: 2}] + // options.initialIframeSrc: the URL of the initial iframe src, + // by default set to "javascript:false;" + $.ajaxTransport('iframe', function (options) { + if (options.async) { + // javascript:false as initial iframe src + // prevents warning popups on HTTPS in IE6: + /*jshint scripturl: true */ + var initialIframeSrc = options.initialIframeSrc || 'javascript:false;', + /*jshint scripturl: false */ + form, + iframe, + addParamChar; + return { + send: function (_, completeCallback) { + form = $('
'); + form.attr('accept-charset', options.formAcceptCharset); + addParamChar = /\?/.test(options.url) ? '&' : '?'; + // XDomainRequest only supports GET and POST: + if (options.type === 'DELETE') { + options.url = options.url + addParamChar + '_method=DELETE'; + options.type = 'POST'; + } else if (options.type === 'PUT') { + options.url = options.url + addParamChar + '_method=PUT'; + options.type = 'POST'; + } else if (options.type === 'PATCH') { + options.url = options.url + addParamChar + '_method=PATCH'; + options.type = 'POST'; + } + // IE versions below IE8 cannot set the name property of + // elements that have already been added to the DOM, + // so we set the name along with the iframe HTML markup: + counter += 1; + iframe = $( + '' + ).bind('load', function () { + var fileInputClones, + paramNames = $.isArray(options.paramName) ? + options.paramName : [options.paramName]; + iframe + .unbind('load') + .bind('load', function () { + var response; + // Wrap in a try/catch block to catch exceptions thrown + // when trying to access cross-domain iframe contents: + try { + response = iframe.contents(); + // Google Chrome and Firefox do not throw an + // exception when calling iframe.contents() on + // cross-domain requests, so we unify the response: + if (!response.length || !response[0].firstChild) { + throw new Error(); + } + } catch (e) { + response = undefined; + } + // The complete callback returns the + // iframe content document as response object: + completeCallback( + 200, + 'success', + {'iframe': response} + ); + // Fix for IE endless progress bar activity bug + // (happens on form submits to iframe targets): + $('') + .appendTo(form); + window.setTimeout(function () { + // Removing the form in a setTimeout call + // allows Chrome's developer tools to display + // the response result + form.remove(); + }, 0); + }); + form + .prop('target', iframe.prop('name')) + .prop('action', options.url) + .prop('method', options.type); + if (options.formData) { + $.each(options.formData, function (index, field) { + $('') + .prop('name', field.name) + .val(field.value) + .appendTo(form); + }); + } + if (options.fileInput && options.fileInput.length && + options.type === 'POST') { + fileInputClones = options.fileInput.clone(); + // Insert a clone for each file input field: + options.fileInput.after(function (index) { + return fileInputClones[index]; + }); + if (options.paramName) { + options.fileInput.each(function (index) { + $(this).prop( + 'name', + paramNames[index] || options.paramName + ); + }); + } + // Appending the file input fields to the hidden form + // removes them from their original location: + form + .append(options.fileInput) + .prop('enctype', 'multipart/form-data') + // enctype must be set as encoding for IE: + .prop('encoding', 'multipart/form-data'); + // Remove the HTML5 form attribute from the input(s): + options.fileInput.removeAttr('form'); + } + form.submit(); + // Insert the file input fields at their original location + // by replacing the clones with the originals: + if (fileInputClones && fileInputClones.length) { + options.fileInput.each(function (index, input) { + var clone = $(fileInputClones[index]); + // Restore the original name and form properties: + $(input) + .prop('name', clone.prop('name')) + .attr('form', clone.attr('form')); + clone.replaceWith(input); + }); + } + }); + form.append(iframe).appendTo(document.body); + }, + abort: function () { + if (iframe) { + // javascript:false as iframe src aborts the request + // and prevents warning popups on HTTPS in IE6. + // concat is used to avoid the "Script URL" JSLint error: + iframe + .unbind('load') + .prop('src', initialIframeSrc); + } + if (form) { + form.remove(); + } + } + }; + } + }); + + // The iframe transport returns the iframe content document as response. + // The following adds converters from iframe to text, json, html, xml + // and script. + // Please note that the Content-Type for JSON responses has to be text/plain + // or text/html, if the browser doesn't include application/json in the + // Accept header, else IE will show a download dialog. + // The Content-Type for XML responses on the other hand has to be always + // application/xml or text/xml, so IE properly parses the XML response. + // See also + // https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#content-type-negotiation + $.ajaxSetup({ + converters: { + 'iframe text': function (iframe) { + return iframe && $(iframe[0].body).text(); + }, + 'iframe json': function (iframe) { + return iframe && $.parseJSON($(iframe[0].body).text()); + }, + 'iframe html': function (iframe) { + return iframe && $(iframe[0].body).html(); + }, + 'iframe xml': function (iframe) { + var xmlDoc = iframe && iframe[0]; + return xmlDoc && $.isXMLDoc(xmlDoc) ? xmlDoc : + $.parseXML((xmlDoc.XMLDocument && xmlDoc.XMLDocument.xml) || + $(xmlDoc.body).html()); + }, + 'iframe script': function (iframe) { + return iframe && $.globalEval($(iframe[0].body).text()); + } + } + }); + +})); diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html new file mode 100644 index 000000000..a28fc5ebf --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -0,0 +1,39 @@ +{% extends "wagtailadmin/base.html" %} +{% load image_tags %} +{% load i18n %} +{% block titletag %}{% trans "Add multiple images" %}{% endblock %} +{% block bodyclass %}menu-images{% endblock %} +{% block extra_css %} + {% include "wagtailadmin/shared/tag_field_css.html" %} +{% endblock %} + +{% block extra_js %} + + + + + + {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} + +{% endblock %} + +{% block content %} + {% trans "Add image" as add_str %} + {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %} + + +
+
+ + {% csrf_token %} +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/confirmation.json b/wagtail/wagtailimages/templates/wagtailimages/multiple/confirmation.json new file mode 100644 index 000000000..293164808 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/confirmation.json @@ -0,0 +1,4 @@ +{ + "image_id": {{ image_id }}, + "success": {% if success %}true{% else %}false{% endif %} +} \ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html new file mode 100644 index 000000000..430ecbb08 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html @@ -0,0 +1,18 @@ +{% load i18n image_tags %} +
  • +
    +
      + {% csrf_token %} + {% for field in form %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endfor %} +
    • + + {% trans "Cancel upload" %} +
    • +
    +
    +
    + {% image image max-800x600 %} +
    +
  • \ No newline at end of file diff --git a/wagtail/wagtailimages/urls.py b/wagtail/wagtailimages/urls.py index 4fa4efaf9..46669cd63 100644 --- a/wagtail/wagtailimages/urls.py +++ b/wagtail/wagtailimages/urls.py @@ -1,5 +1,5 @@ from django.conf.urls import url -from wagtail.wagtailimages.views import images, chooser +from wagtail.wagtailimages.views import images, chooser, multiple urlpatterns = [ url(r'^$', images.index, name='wagtailimages_index'), @@ -7,6 +7,10 @@ urlpatterns = [ url(r'^(\d+)/delete/$', images.delete, name='wagtailimages_delete_image'), url(r'^add/$', images.add, name='wagtailimages_add_image'), + url(r'^multiple/add/$', multiple.add, name='wagtailimages_add_multiple'), + url(r'^multiple/(\d+)/$', multiple.edit, name='wagtailimages_edit_multiple'), + url(r'^multiple/(\d+)/delete$', multiple.delete, name='wagtailimages_delete_multiple'), + url(r'^chooser/$', chooser.chooser, name='wagtailimages_chooser'), url(r'^chooser/(\d+)/$', chooser.image_chosen, name='wagtailimages_image_chosen'), url(r'^chooser/upload/$', chooser.chooser_upload, name='wagtailimages_chooser_upload'), diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py new file mode 100644 index 000000000..7cf55c690 --- /dev/null +++ b/wagtail/wagtailimages/views/multiple.py @@ -0,0 +1,87 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib import messages +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.contrib.auth.decorators import permission_required +from django.core.exceptions import PermissionDenied +from django.utils.translation import ugettext as _ +from django.views.decorators.vary import vary_on_headers + + +from django.forms.models import modelformset_factory +from django.template.loader import render_to_string +from django.http import HttpResponse + +from wagtail.wagtailadmin.forms import SearchForm + +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.forms import get_image_form_for_multi + +import json + +@permission_required('wagtailimages.add_image') +@vary_on_headers('X-Requested-With') +def add(request): + ImageForm = get_image_form_for_multi() + ImageModel = get_image_model() + + if request.POST and request.is_ajax(): + if not request.FILES: + return HttpResponseBadRequest('Must upload a file') + else: + image = ImageModel(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) + image.save() + form = ImageForm(instance=image, prefix='image-%d'%image.id) + + return render(request, 'wagtailimages/multiple/edit.html', { + 'image': image, + 'form': form + }) + else: + pass + + return render(request, "wagtailimages/multiple/add.html", {}) + +@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view +def edit(request, image_id, callback=None): + Image = get_image_model() + ImageForm = get_image_form_for_multi() + + image = get_object_or_404(Image, id=image_id) + + if not image.is_editable_by_user(request.user): + raise PermissionDenied + + if request.POST: + form = ImageForm(request.POST, request.FILES, instance=image, prefix='image-'+image_id) + if form.is_valid(): + form.save() + return HttpResponse(render_to_string("wagtailimages/multiple/confirmation.json", { + 'success': True, + 'image_id': image_id + })) + else: + pass + + return HttpResponse(render_to_string("wagtailimages/multiple/confirmation.json", { + 'success': False, + 'image_id': image_id + })) + +@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view +def delete(request, image_id): + image = get_object_or_404(get_image_model(), id=image_id) + + if not image.is_editable_by_user(request.user): + raise PermissionDenied + + if request.POST: + image.delete() + return HttpResponse(render_to_string("wagtailimages/multiple/confirmation.json", { + 'success': True, + 'image_id': image_id + })) + else: + return HttpResponse(render_to_string("wagtailimages/multiple/confirmation.json", { + 'success': False, + 'image_id': image_id + })) \ No newline at end of file From c880ef3b84b0835daa395cb226be598821f81b49 Mon Sep 17 00:00:00 2001 From: Ben Margolis Date: Sun, 15 Jun 2014 22:46:27 -0700 Subject: [PATCH 02/62] Update forms.py --- wagtail/wagtailimages/forms.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/wagtail/wagtailimages/forms.py b/wagtail/wagtailimages/forms.py index 248cc1db1..4d9cc7151 100644 --- a/wagtail/wagtailimages/forms.py +++ b/wagtail/wagtailimages/forms.py @@ -17,9 +17,7 @@ def get_image_form(): def get_image_form_for_multi(): return modelform_factory( get_image_model(), - # set the 'file' widget to a FileInput rather than the default ClearableFileInput - # so that when editing, we don't get the 'currently: ...' banner which is - # a bit pointless here + # exclude the file widget exclude=('file',)) From ac87fe65be7efb51aa9b7d2eda956cf7825fce80 Mon Sep 17 00:00:00 2001 From: Ben Margolis Date: Sun, 15 Jun 2014 22:49:47 -0700 Subject: [PATCH 03/62] fix a typo and spacing --- wagtail/wagtailimages/forms.py | 6 ++---- .../wagtailimages/static/wagtailimages/js/add-multiple.js | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/wagtail/wagtailimages/forms.py b/wagtail/wagtailimages/forms.py index 4d9cc7151..57ef11ac5 100644 --- a/wagtail/wagtailimages/forms.py +++ b/wagtail/wagtailimages/forms.py @@ -15,10 +15,8 @@ def get_image_form(): def get_image_form_for_multi(): - return modelform_factory( - get_image_model(), - # exclude the file widget - exclude=('file',)) + # exclude the file widget + return modelform_factory( get_image_model(), exclude=('file',)) class ImageInsertionForm(forms.Form): diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 0b83c338f..b0e6d1126 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -33,7 +33,6 @@ $(function () { jform.find('#id_'+ im_li.attr('id') +'-tags').tagit(window.tagit_opts); }); - im_li $("#image-forms").append(im_li); } }); From fc9f001e718e3bf09254aca462f07126cb9de0a2 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Wed, 18 Jun 2014 11:39:32 +0100 Subject: [PATCH 04/62] test --- wagtail/wagtailimages/templates/wagtailimages/multiple/add.html | 1 + 1 file changed, 1 insertion(+) diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index a28fc5ebf..ea900a6ae 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -15,6 +15,7 @@ {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} - - - - - {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} - -{% endblock %} - {% block content %} - {% trans "Add image" as add_str %} + {% trans "Add images" as add_str %} {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
    @@ -31,8 +19,62 @@ {% csrf_token %} -
      +
      +
      +
      + +
        +
      • Drag and drop images into this area to upload
    + + +
      +
    • +
      +
      +
      +
      +
      +
      +
      +
      +
      +
    • +
    + +{% endblock %} + +{% block extra_js %} + + + + + + + + + + + + + {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} + {% endblock %} \ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html similarity index 54% rename from wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html rename to wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html index 430ecbb08..e5539e4e9 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html @@ -1,6 +1,6 @@ {% load i18n image_tags %} -
  • -
    + + -
    -
    - {% image image max-800x600 %} -
    -
  • \ No newline at end of file + +
    + {% image image max-200x200 %} +
    \ No newline at end of file diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py index 7cf55c690..a22ec2f02 100644 --- a/wagtail/wagtailimages/views/multiple.py +++ b/wagtail/wagtailimages/views/multiple.py @@ -32,7 +32,7 @@ def add(request): image.save() form = ImageForm(instance=image, prefix='image-%d'%image.id) - return render(request, 'wagtailimages/multiple/edit.html', { + return render(request, 'wagtailimages/multiple/edit_form.html', { 'image': image, 'form': form }) From 419b562f6404e29e29f6e28d98d64b2f65b29ade Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Mon, 30 Jun 2014 09:27:44 +0100 Subject: [PATCH 07/62] ongoing work on multi uploader --- .../static/wagtailadmin/js/core.js | 7 ++ .../wagtailadmin/scss/components/forms.scss | 7 +- .../scss/components/progressbar.scss | 7 +- .../static/wagtailimages/js/add-multiple.js | 16 ++--- .../wagtailimages/scss/add-multiple.scss | 67 ++++++++++++++----- .../templates/wagtailimages/multiple/add.html | 39 +++++++++-- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/core.js b/wagtail/wagtailadmin/static/wagtailadmin/js/core.js index 85c4ca31e..580ff5b24 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/js/core.js +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/core.js @@ -102,6 +102,13 @@ $(function(){ $(this).addClass('focussed'); }); + /* Dropzones */ + $('.drop-zone').on('dragover', function(){ + $(this).addClass('hovered'); + }).on('dragleave dragend drop', function(){ + $(this).removeClass('hovered'); + }); + /* Header search behaviour */ if(window.headerSearch){ var search_current_index = 0; diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss index 3de3eb526..34bf1e57f 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss @@ -676,12 +676,17 @@ ul.tagit li.tagit-choice-editable{ /* file drop zones */ .drop-zone{ @include border-radius(5px); - border:5px dashed $color-grey-4; + border:2px dashed $color-grey-4; padding:$mobile-nice-padding; + background-color:$color-grey-5; .drop-zone-help{ border:0; } + &.hovered{ + border-color:$color-teal; + background-color:$color-input-focus; + } } diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss index 83307c02b..1b95b9120 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss @@ -1,12 +1,11 @@ .progress{ - @include border-radius(1.5em); - @include box-shadow(0 0 5px 2px rgba(255, 255, 255, 0.4)); + @include border-radius(1.2em); background-color:$color-teal-dark; - border:1px solid white; + border:1px solid $color-teal; .bar{ @include border-radius(1.5em); background-color:$color-teal; - height:1.5em; + height:1.2em; } } \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index f9d5f5153..085007b5f 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -34,8 +34,8 @@ $(function(){ return $this.fileupload('process', data); }).always(function () { data.context.removeClass('processing'); - data.context.find('.preview').each(function (index, elm) { - console.log(data.files[index]); + data.context.find('.preview .thumb').each(function (index, elm) { + $(elm).addClass('hasthumb') $(elm).append(data.files[index].preview); }); }).done(function () { @@ -103,15 +103,15 @@ $(function(){ //jform.find('#id_'+ im_li.attr('id') +'-tags').tagit(window.tagit_opts); }); }, - + + fail: function(e, data){ + var itemElement = $(data.context); + itemElement.addClass('failed'); + }, + always: function(e, data){ var itemElement = $(data.context); itemElement.removeClass('uploading'); }, - - fail: function(e, data){ - var itemElement = $(data.context); - itemElement.addClass('failed'); - } }); }); \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss index 0b293c146..a305c9006 100644 --- a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss +++ b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss @@ -2,39 +2,70 @@ @import "../../wagtailadmin/static/wagtailadmin/scss/mixins.scss"; @import "../../wagtailadmin/static/wagtailadmin/scss/grid.scss"; +.drop-zone-help{ + background-color:transparent; +} + .upload-list{ + .preview{ width:150px; min-height:150px; - border: 3px solid $color-grey-4; display:block; position:relative; text-align:center; padding:1em; - - .progress{ - position:absolute; - z-index:2; - top:60%; - left:10%; - right:10%; - width:80%; - } + } + .progress, .thumb, .thumb:before, canvas, img{ + position:absolute; + } + .progress{ + z-index:4; + top:60%; + left:20%; + right:20%; + width:60%; + @include box-shadow(0 0 5px 2px rgba(255, 255, 255, 0.4)); + border:1px solid white; } .thumb{ - position:absolute; + top:0;right:0;bottom:0;left:0; z-index:1; - top:0; - bottom:0; + } + + .thumb:before, canvas, img{ left:0; right:0; - } - .thumb:before{ - position:absolute; top:0; bottom:0; - font-size:9em; + margin:auto; + } + .thumb:before{ + z-index:2; + top:0; + width:1em; + font-size:10em; + line-height:1.4em; color:lighten($color-grey-4, 4%); - margin:0; + } + canvas, img{ + z-index:3; + } + + .hasthumb{ + .thumb:before{ + display:none; + } + } + + + .uploading{ + + } + .done{ + border-color:$color-green; + } + .failed{ + border-color:$color-red; } } \ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index f1eec788c..5efc07d0c 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -23,9 +23,10 @@
    -
      -
    • Drag and drop images into this area to upload
    • -
    +
    +

    Drag and drop images into this area to upload

    +
      +
        -
      • +
      • -
        +
        + +
        +
        +
        +
        +
        +
        +
        +
      • +
      • +
        +
        +
        + +
        +
        +
        +
        +
        +
        +
        +
      • +
      • +
        +
        +
        + +
        From 11223876267f092e3e9d08f469f2422f84922b1b Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Fri, 4 Jul 2014 17:07:35 +0100 Subject: [PATCH 08/62] ongoing styling of multi-upload --- .../scss/components/formatters.scss | 11 +++- .../wagtailadmin/scss/components/forms.scss | 1 + .../scss/components/progressbar.scss | 17 ++++++ .../static/wagtailimages/js/add-multiple.js | 25 +++++--- .../wagtailimages/scss/add-multiple.scss | 57 ++++++++++++++++--- .../templates/wagtailimages/multiple/add.html | 39 ++++++++----- .../wagtailimages/multiple/edit_form.html | 9 +-- 7 files changed, 120 insertions(+), 39 deletions(-) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss index 97e915f36..8a71bc9e9 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss @@ -186,4 +186,13 @@ a.tag:hover{ /* utility class to allow things to be scrollable if their contents can't wrap more nicely */ .overflow{ overflow:auto; -} \ No newline at end of file +} + +.status-msg{ + &.success{ + color:$color-green; + } + &.failure{ + color:$color-red; + } +} diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss index 140990a00..339b2ae64 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss @@ -728,6 +728,7 @@ ul.tagit li.tagit-choice-editable{ border:2px dashed $color-grey-4; padding:$mobile-nice-padding; background-color:$color-grey-5; + margin-bottom:1em; .drop-zone-help{ border:0; diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss index 1b95b9120..258c6e28e 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss @@ -1,11 +1,28 @@ .progress{ @include border-radius(1.2em); + @include transition(opacity 0.3s ease); background-color:$color-teal-dark; border:1px solid $color-teal; + opacity:0; + + &.active{ + opacity:1; + } + &.done{ + opacity:0; + } .bar{ @include border-radius(1.5em); + @include transition(width 0.3s ease); + overflow:hidden; + box-sizing:border-box; + text-align:right; + line-height:1.2em; + color:white; + font-size:0.85em; background-color:$color-teal; height:1.2em; + padding-right:1em; } } \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 085007b5f..8f2268400 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -29,11 +29,14 @@ $(function(){ $('#upload-list').append(li); data.context = li; - + data.process(function () { return $this.fileupload('process', data); }).always(function () { data.context.removeClass('processing'); + data.context.find('.left').each(function(index, elm){ + $(elm).append(data.files[index].name); + }); data.context.find('.preview .thumb').each(function (index, elm) { $(elm).addClass('hasthumb') $(elm).append(data.files[index].preview); @@ -68,21 +71,27 @@ $(function(){ $(this).find('.progress').attr('aria-valuenow', progress).find('.bar').css( 'width', progress + '%' - ); + ).html(progress + '%'); }); }, progressall: function (e, data) { var progress = parseInt(data.loaded / data.total * 100, 10); - $('#overall-progress').attr('aria-valuenow', progress).find('.bar').css( + $('#overall-progress').removeClass('done').addClass('active').attr('aria-valuenow', progress).find('.bar').css( 'width', progress + '%' - ); + ).html(progress + '%'); + + console.log(progress); + if (progress >= 100){ + $('#overall-progress').removeClass('active').addClass('done'); + } }, done: function (e, data) { var itemElement = $(data.context); - $('.right', itemElement).append(data.result).addClass('done'); + itemElement.addClass('upload-success') + $('.right', itemElement).append(data.result); $('form', itemElement).each(function(){ var jform = $(this); @@ -99,19 +108,17 @@ $(function(){ }); }); - - //jform.find('#id_'+ im_li.attr('id') +'-tags').tagit(window.tagit_opts); }); }, fail: function(e, data){ var itemElement = $(data.context); - itemElement.addClass('failed'); + itemElement.addClass('upload-failure'); }, always: function(e, data){ var itemElement = $(data.context); - itemElement.removeClass('uploading'); + itemElement.removeClass('upload-uploading'); }, }); }); \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss index a305c9006..2123f4d20 100644 --- a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss +++ b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss @@ -2,11 +2,36 @@ @import "../../wagtailadmin/static/wagtailadmin/scss/mixins.scss"; @import "../../wagtailadmin/static/wagtailadmin/scss/grid.scss"; -.drop-zone-help{ - background-color:transparent; +.replace-file-input{ + position:relative; + + input[type=file]{ + opacity:0; + position:absolute; + top:0; + left:0; + + &:hover{ + cursor:pointer; + } + } + + &:hover{ + cursor:pointer; + + button{ + background-color:$color-teal-darker; + } + } } .upload-list{ + > li{ + padding:1em; + } + .left{ + text-align:center; + } .preview{ width:150px; @@ -14,10 +39,12 @@ display:block; position:relative; text-align:center; - padding:1em; + max-width:100%; + margin:auto; } .progress, .thumb, .thumb:before, canvas, img{ position:absolute; + max-width:100%; } .progress{ z-index:4; @@ -26,11 +53,11 @@ right:20%; width:60%; @include box-shadow(0 0 5px 2px rgba(255, 255, 255, 0.4)); - border:1px solid white; } .thumb{ top:0;right:0;bottom:0;left:0; z-index:1; + width:100%; } .thumb:before, canvas, img{ @@ -53,19 +80,31 @@ } .hasthumb{ - .thumb:before{ + &:before{ display:none; } } + .status-msg{ + display:none; + } - .uploading{ + .upload-uploading{ } - .done{ - border-color:$color-green; + .upload-success{ + .progress{ + opacity:0; + } + .status-msg.success{ + display:block; + } } - .failed{ + .upload-failure{ border-color:$color-red; + + .status-msg.failure{ + display:block; + } } } \ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index 5efc07d0c..5fd81b29b 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -14,24 +14,27 @@ {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
        -
        - - {% csrf_token %} -
        +
        +

        Drag and drop images into this area to upload

        + +
        + + + {% csrf_token %} +
        +
        -
        +
        0%
        -
        -

        Drag and drop images into this area to upload

        -
          -
          +
            +
            + {% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html index e5539e4e9..87a72949a 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html @@ -1,7 +1,7 @@ -{% load i18n image_tags %} +{% load i18n %}
            -
              +
                {% csrf_token %} {% for field in form %} {% include "wagtailadmin/shared/field_as_li.html" %} @@ -11,7 +11,4 @@ {% trans "Delete" %}
              - -
              - {% image image max-200x200 %} -
              \ No newline at end of file + \ No newline at end of file From 936c10418790f859ea386d43be2ba13a2083cac8 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Fri, 4 Jul 2014 17:15:53 +0100 Subject: [PATCH 09/62] ajax form fixed --- .../static/wagtailimages/js/add-multiple.js | 23 +++++++++++-------- .../wagtailimages/multiple/edit_form.html | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 8f2268400..36a76b099 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -1,10 +1,5 @@ $(function(){ - function process_result(data) { - var result = $.parseJSON(data); - if (result.success) { - $('li#image-'+result.image_id).slideUp(function() { $(this).remove(); }); - } - } + // prevents browser default drag/drop $(document).bind('drop dragover', function (e) { @@ -98,13 +93,23 @@ $(function(){ jform.submit(function(event) { //convert save to an ajax call event.preventDefault(); - $.post(this.action, $(this).serialize(), process_result); + $.post(this.action, $(this).serialize(), function(data) { + var result = $.parseJSON(data); + if (result.success) { + itemElement.slideUp(function(){$(this).remove()}); + } + }); }); - jform.find('a').each(function(){ //convert delete to an ajax call + jform.find('.delete').each(function(){ //convert delete to an ajax call $(this).click(function(event) { event.preventDefault(); - $.post(this.href, jform.serialize(), process_result); + $.post(this.href, jform.serialize(), function(data) { + var result = $.parseJSON(data); + if (result.success) { + itemElement.slideUp(function(){$(this).remove()}); + } + }); }); }); diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html index 87a72949a..05efddf52 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html @@ -8,7 +8,7 @@ {% endfor %}
            • - {% trans "Delete" %} + {% trans "Delete" %}
            \ No newline at end of file From 508e85a0cbceec5ecae98698186f3607a6ae0a56 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Mon, 7 Jul 2014 10:14:23 +0100 Subject: [PATCH 10/62] further tweaks to upload ui --- .../static/wagtailadmin/scss/components/progressbar.scss | 9 +++------ .../static/wagtailimages/js/add-multiple.js | 9 ++++----- .../static/wagtailimages/scss/add-multiple.scss | 8 +++++--- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss index 258c6e28e..472b55859 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/progressbar.scss @@ -1,16 +1,13 @@ .progress{ @include border-radius(1.2em); - @include transition(opacity 0.3s ease); background-color:$color-teal-dark; border:1px solid $color-teal; opacity:0; &.active{ - opacity:1; - } - &.done{ - opacity:0; - } + opacity:1; + @include transition(opacity 0.3s ease); + } .bar{ @include border-radius(1.5em); diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 36a76b099..21cc210de 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -63,7 +63,7 @@ $(function(){ var progress = Math.floor(data.loaded / data.total * 100); data.context.each(function () { - $(this).find('.progress').attr('aria-valuenow', progress).find('.bar').css( + $(this).find('.progress').addClass('active').attr('aria-valuenow', progress).find('.bar').css( 'width', progress + '%' ).html(progress + '%'); @@ -72,14 +72,13 @@ $(function(){ progressall: function (e, data) { var progress = parseInt(data.loaded / data.total * 100, 10); - $('#overall-progress').removeClass('done').addClass('active').attr('aria-valuenow', progress).find('.bar').css( + $('#overall-progress').addClass('active').attr('aria-valuenow', progress).find('.bar').css( 'width', progress + '%' ).html(progress + '%'); - console.log(progress); if (progress >= 100){ - $('#overall-progress').removeClass('active').addClass('done'); + $('#overall-progress').removeClass('active').find('.bar').css('width','0%'); } }, @@ -123,7 +122,7 @@ $(function(){ always: function(e, data){ var itemElement = $(data.context); - itemElement.removeClass('upload-uploading'); + itemElement.removeClass('upload-uploading').addClass('upload-complete'); }, }); }); \ No newline at end of file diff --git a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss index 2123f4d20..44e0c9138 100644 --- a/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss +++ b/wagtail/wagtailimages/static/wagtailimages/scss/add-multiple.scss @@ -89,13 +89,15 @@ display:none; } + .upload-complete{ + .progress{ + opacity:0; + } + } .upload-uploading{ } .upload-success{ - .progress{ - opacity:0; - } .status-msg.success{ display:block; } From 85c9e3eaafe4622845544ba75fc05f4a254d9496 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Mon, 7 Jul 2014 10:34:12 +0100 Subject: [PATCH 11/62] getting tag fields working --- .../static/wagtailimages/js/add-multiple.js | 3 +- .../templates/wagtailimages/multiple/add.html | 59 ++----------------- 2 files changed, 8 insertions(+), 54 deletions(-) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 21cc210de..699037c84 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -110,8 +110,9 @@ $(function(){ } }); }); - }); + + jform.find('.tag_field input').tagit(window.tagit_opts); }); }, diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index 5fd81b29b..d120ac988 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -15,10 +15,10 @@
            -

            Drag and drop images into this area to upload

            +

            {% trans "Drag and drop images into this area to upload" %}

            - + {% csrf_token %}
            @@ -29,7 +29,6 @@
              -
              - - - {% endblock %} {% block extra_js %} @@ -111,6 +62,8 @@ + {% include "wagtailadmin/shared/tag_field_js.html" %} + {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} {% include "wagtailadmin/shared/tag_field_js.html" %} - + {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py index d09015e2a..0dfdd31cc 100644 --- a/wagtail/wagtailimages/views/multiple.py +++ b/wagtail/wagtailimages/views/multiple.py @@ -8,6 +8,7 @@ from django.views.decorators.vary import vary_on_headers from django.http import HttpResponse, HttpResponseBadRequest from django.template import RequestContext from django.template.loader import render_to_string +from django.utils.translation import ugettext as _ from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.forms import get_image_form_for_multi @@ -30,14 +31,23 @@ def add(request): if not request.FILES: return HttpResponseBadRequest("Must upload a file") - image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) - image.save() - form = ImageForm(instance=image, prefix='image-%d'%image.id) + try: + image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) + image.save() + form = ImageForm(instance=image, prefix='image-%d' % image.id) + except: + return json_response({ + 'success': False, + 'error_message': _("An error occurred: TODO"), + }) - return render(request, 'wagtailimages/multiple/edit_form.html', { - 'image': image, - 'form': form - }) + return json_response({ + 'success': True, + 'form': render_to_string('wagtailimages/multiple/edit_form.html', { + 'image': image, + 'form': form + }, context_instance=RequestContext(request)) + }) return render(request, 'wagtailimages/multiple/add.html', {}) From 126aab2c807c6927f7f836e04dcc4759dc349dbc Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Thu, 17 Jul 2014 12:09:24 +0100 Subject: [PATCH 35/62] made TODO clearer --- wagtail/wagtailimages/views/multiple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py index 0dfdd31cc..e175360fd 100644 --- a/wagtail/wagtailimages/views/multiple.py +++ b/wagtail/wagtailimages/views/multiple.py @@ -38,7 +38,7 @@ def add(request): except: return json_response({ 'success': False, - 'error_message': _("An error occurred: TODO"), + 'error_message': _("TODO: add here the message explaining what exactly occurred."), }) return json_response({ From c16d0a89f8bae1e415ce3bfb9a972b9949753a92 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Fri, 18 Jul 2014 10:25:53 +0100 Subject: [PATCH 36/62] added msg --- .../wagtailimages/templates/wagtailimages/multiple/add.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index 606bee94f..8b3f29ec4 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -14,6 +14,12 @@ {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
              +
              +

              File formats supported: jp(e)g, gif, png

              +

              Images will be automatically uploaded once chosen/dropped.

              + +
              +

              {% trans "Drag and drop images into this area to upload immediately." %}

              From a0d79fcff1e25d6dc94e07277a50dd5a8ea940b2 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Fri, 18 Jul 2014 10:29:46 +0100 Subject: [PATCH 37/62] removed help, wrong place --- .../wagtailimages/templates/wagtailimages/multiple/add.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index 8b3f29ec4..606bee94f 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -14,12 +14,6 @@ {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
              -
              -

              File formats supported: jp(e)g, gif, png

              -

              Images will be automatically uploaded once chosen/dropped.

              - -
              -

              {% trans "Drag and drop images into this area to upload immediately." %}

              From 050a85203e14f3fb06aae7535fbdcba6735c44ca Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 13:05:34 +0100 Subject: [PATCH 38/62] Fixed delete button --- .../wagtailimages/static/wagtailimages/js/add-multiple.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index d0763bc73..444671428 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -120,11 +120,13 @@ $(function(){ }); $('#upload-list').on('click', '.delete', function(e){ - var form = $(this); + var form = $(this).closest('form'); e.preventDefault(); - $.post(this.href, form.serialize(), function(data) { + var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val(); + + $.post(this.href, {csrfmiddlewaretoken: CSRFToken}, function(data) { if (data.success) { itemElement.slideUp(function(){$(this).remove()}); }else{ From 094a315f21212c3511a70ad7a413300a61b5e058 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 13:06:29 +0100 Subject: [PATCH 39/62] Fixed a bad URL --- wagtail/wagtailimages/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailimages/urls.py b/wagtail/wagtailimages/urls.py index 46669cd63..4f8744f18 100644 --- a/wagtail/wagtailimages/urls.py +++ b/wagtail/wagtailimages/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ url(r'^multiple/add/$', multiple.add, name='wagtailimages_add_multiple'), url(r'^multiple/(\d+)/$', multiple.edit, name='wagtailimages_edit_multiple'), - url(r'^multiple/(\d+)/delete$', multiple.delete, name='wagtailimages_delete_multiple'), + url(r'^multiple/(\d+)/delete/$', multiple.delete, name='wagtailimages_delete_multiple'), url(r'^chooser/$', chooser.chooser, name='wagtailimages_chooser'), url(r'^chooser/(\d+)/$', chooser.image_chosen, name='wagtailimages_image_chosen'), From b29f3851a108aad7a772903fed37ad2617e51afe Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 16:26:10 +0100 Subject: [PATCH 40/62] Run validation on uploaded images and display validation errors to the user --- wagtail/wagtailimages/views/multiple.py | 26 ++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py index e175360fd..c64d70514 100644 --- a/wagtail/wagtailimages/views/multiple.py +++ b/wagtail/wagtailimages/views/multiple.py @@ -3,7 +3,7 @@ import json from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import permission_required from django.views.decorators.http import require_POST -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.views.decorators.vary import vary_on_headers from django.http import HttpResponse, HttpResponseBadRequest from django.template import RequestContext @@ -33,22 +33,26 @@ def add(request): try: image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) + image.full_clean() image.save() + + # Success! Send back an edit form for this image to the user form = ImageForm(instance=image, prefix='image-%d' % image.id) - except: + + return json_response({ + 'success': True, + 'image_id': int(image.id), + 'form': render_to_string('wagtailimages/multiple/edit_form.html', { + 'image': image, + 'form': form, + }, context_instance=RequestContext(request)), + }) + except ValidationError as e: return json_response({ 'success': False, - 'error_message': _("TODO: add here the message explaining what exactly occurred."), + 'error_message': '\n'.join(e.messages), }) - return json_response({ - 'success': True, - 'form': render_to_string('wagtailimages/multiple/edit_form.html', { - 'image': image, - 'form': form - }, context_instance=RequestContext(request)) - }) - return render(request, 'wagtailimages/multiple/add.html', {}) From f471ca623dc9ceb8bdfcbbc1f5e944ce34aeef64 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 16:42:00 +0100 Subject: [PATCH 41/62] Improvements to image file validation --- wagtail/wagtailimages/utils.py | 9 ++++++- wagtail/wagtailimages/views/multiple.py | 35 ++++++++++++++----------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/wagtail/wagtailimages/utils.py b/wagtail/wagtailimages/utils.py index 2f0c0e676..17903ce93 100644 --- a/wagtail/wagtailimages/utils.py +++ b/wagtail/wagtailimages/utils.py @@ -20,9 +20,16 @@ def validate_image_format(f): # Open image file file_position = f.tell() f.seek(0) - image = Image.open(f) + + try: + image = Image.open(f) + except IOError: + # Uploaded file is not even an image file (or corrupted) + raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension.")) + f.seek(file_position) # Check that the internal format matches the extension + # It is possible to upload PSD files if their extension is set to jpg, png or gif. This should catch them out if image.format.upper() != extension.upper(): raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension.") % (extension.upper())) diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py index c64d70514..7a3702625 100644 --- a/wagtail/wagtailimages/views/multiple.py +++ b/wagtail/wagtailimages/views/multiple.py @@ -12,6 +12,7 @@ from django.utils.translation import ugettext as _ from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.forms import get_image_form_for_multi +from wagtail.wagtailimages.utils import validate_image_format def json_response(document): @@ -31,28 +32,32 @@ def add(request): if not request.FILES: return HttpResponseBadRequest("Must upload a file") + # Check that the uploaded file is valid try: - image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) - image.full_clean() - image.save() - - # Success! Send back an edit form for this image to the user - form = ImageForm(instance=image, prefix='image-%d' % image.id) - - return json_response({ - 'success': True, - 'image_id': int(image.id), - 'form': render_to_string('wagtailimages/multiple/edit_form.html', { - 'image': image, - 'form': form, - }, context_instance=RequestContext(request)), - }) + validate_image_format(request.FILES['files[]']) except ValidationError as e: return json_response({ 'success': False, 'error_message': '\n'.join(e.messages), }) + # Save it + image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]']) + image.save() + + # Success! Send back an edit form for this image to the user + form = ImageForm(instance=image, prefix='image-%d' % image.id) + + return json_response({ + 'success': True, + 'image_id': int(image.id), + 'form': render_to_string('wagtailimages/multiple/edit_form.html', { + 'image': image, + 'form': form, + }, context_instance=RequestContext(request)), + }) + + return render(request, 'wagtailimages/multiple/add.html', {}) From abce45477e7a995beab3cd56212ff239713a3c4a Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 16:42:15 +0100 Subject: [PATCH 42/62] Updated tests --- wagtail/wagtailimages/tests.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 6367b1ecc..f1ec85801 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -508,6 +508,7 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): # Check response self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/json') self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html') # Check image @@ -518,6 +519,14 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): self.assertIn('form', response.context) self.assertEqual(response.context['form'].initial['title'], 'test.png') + # Check JSON + response_json = json.loads(response.content.decode()) + self.assertIn('image_id', response_json) + self.assertIn('form', response_json) + self.assertIn('success', response_json) + self.assertEqual(response_json['image_id'], response.context['image'].id) + self.assertTrue(response_json['success']) + def test_add_post_noajax(self): """ This tests that only AJAX requests are allowed to POST to the add view @@ -536,6 +545,27 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): # Check response self.assertEqual(response.status_code, 400) + def test_add_post_badfile(self): + """ + This tests that the add view checks for a file when a user POSTs to it + """ + response = self.client.post(reverse('wagtailimages_add_multiple'), { + 'files[]': SimpleUploadedFile('test.png', "This is not an image!"), + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + # Check response + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/json') + + # Check JSON + response_json = json.loads(response.content.decode()) + self.assertNotIn('image_id', response_json) + self.assertNotIn('form', response_json) + self.assertIn('success', response_json) + self.assertIn('error_message', response_json) + self.assertFalse(response_json['success']) + self.assertEqual(response_json['error_message'], 'Not a valid image. Please use a gif, jpeg or png file with the correct file extension.') + def test_edit_get(self): """ This tests that a GET request to the edit view returns a 405 "METHOD NOT ALLOWED" response From 78cbf657dc0d1a3d4a7addb28287f8583169d709 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 17:09:46 +0100 Subject: [PATCH 43/62] Fixed python 3 error --- wagtail/wagtailimages/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index f1ec85801..58c7207bf 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -550,7 +550,7 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): This tests that the add view checks for a file when a user POSTs to it """ response = self.client.post(reverse('wagtailimages_add_multiple'), { - 'files[]': SimpleUploadedFile('test.png', "This is not an image!"), + 'files[]': SimpleUploadedFile('test.png', b"This is not an image!"), }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') # Check response From a1c5dbd12c83232d6a4a5782548de58473406c2e Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 18 Jul 2014 17:12:28 +0100 Subject: [PATCH 44/62] Updated image file validation error message --- wagtail/wagtailimages/tests.py | 2 +- wagtail/wagtailimages/utils.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 58c7207bf..4b0de3a2f 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -564,7 +564,7 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): self.assertIn('success', response_json) self.assertIn('error_message', response_json) self.assertFalse(response_json['success']) - self.assertEqual(response_json['error_message'], 'Not a valid image. Please use a gif, jpeg or png file with the correct file extension.') + self.assertEqual(response_json['error_message'], 'Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).') def test_edit_get(self): """ diff --git a/wagtail/wagtailimages/utils.py b/wagtail/wagtailimages/utils.py index 17903ce93..4d10e2360 100644 --- a/wagtail/wagtailimages/utils.py +++ b/wagtail/wagtailimages/utils.py @@ -14,7 +14,7 @@ def validate_image_format(f): extension = 'jpeg' if extension not in ['gif', 'jpeg', 'png']: - raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension.")) + raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).")) if not f.closed: # Open image file @@ -25,11 +25,11 @@ def validate_image_format(f): image = Image.open(f) except IOError: # Uploaded file is not even an image file (or corrupted) - raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension.")) + raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).")) f.seek(file_position) # Check that the internal format matches the extension # It is possible to upload PSD files if their extension is set to jpg, png or gif. This should catch them out if image.format.upper() != extension.upper(): - raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension.") % (extension.upper())) + raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).") % (extension.upper())) From 5d86d95a217f208ba8f70f2c8503f9849b766445 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Fri, 18 Jul 2014 17:31:49 +0100 Subject: [PATCH 45/62] fixing issue where all forms would submit at once --- .../static/wagtailimages/js/add-multiple.js | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js index 444671428..05c72feb4 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/add-multiple.js @@ -90,9 +90,6 @@ $(function(){ var itemElement = $(data.context); var response = $.parseJSON(data.result); - console.log(e); - console.log(data); - if(response.success){ itemElement.addClass('upload-success') @@ -100,41 +97,6 @@ $(function(){ // run tagit enhancement $('.tag_field input', itemElement).tagit(window.tagit_opts); - - // ajax-enhance forms added on done() - $('#upload-list').on('submit', 'form', function(e){ - var form = $(this); - - e.preventDefault(); - - $.post(this.action, form.serialize(), function(data) { - if (data.success) { - itemElement.slideUp(function(){$(this).remove()}); - }else{ - console.log(data); - form.replaceWith(data.form); - // run tagit enhancement on new form - $('.tag_field input', form).tagit(window.tagit_opts); - } - }); - }); - - $('#upload-list').on('click', '.delete', function(e){ - var form = $(this).closest('form'); - - e.preventDefault(); - - var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val(); - - $.post(this.href, {csrfmiddlewaretoken: CSRFToken}, function(data) { - if (data.success) { - itemElement.slideUp(function(){$(this).remove()}); - }else{ - - } - }); - }); - } else { itemElement.addClass('upload-failure'); $('.right .error_messages', itemElement).append(response.error_message); @@ -152,4 +114,42 @@ $(function(){ itemElement.removeClass('upload-uploading').addClass('upload-complete'); }, }); + + // ajax-enhance forms added on done() + $('#upload-list').on('submit', 'form', function(e){ + var form = $(this); + var itemElement = form.closest('#upload-list > li'); + + console.log(form); + + e.preventDefault(); + + $.post(this.action, form.serialize(), function(data) { + if (data.success) { + itemElement.slideUp(function(){$(this).remove()}); + }else{ + form.replaceWith(data.form); + // run tagit enhancement on new form + $('.tag_field input', form).tagit(window.tagit_opts); + } + }); + }); + + $('#upload-list').on('click', '.delete', function(e){ + var form = $(this).closest('form'); + var itemElement = form.closest('#upload-list > li'); + + e.preventDefault(); + + var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val(); + + $.post(this.href, {csrfmiddlewaretoken: CSRFToken}, function(data) { + if (data.success) { + itemElement.slideUp(function(){$(this).remove()}); + }else{ + + } + }); + }); + }); \ No newline at end of file From b483be7106efef4b7b682647d684edb5ae09ee43 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Tue, 22 Jul 2014 17:24:03 +0100 Subject: [PATCH 46/62] Change jquery datetimepicker's format to match Django's (YYYY-MM-DD hh:mm:ss) - fixes #479 --- wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js | 6 +++--- wagtail/wagtailcore/models.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js index a5dd67eeb..d900d8d0c 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js @@ -98,7 +98,7 @@ function initTimeChooser(id) { function initDateTimeChooser(id) { if (window.dateTimePickerTranslations) { $('#' + id).datetimepicker({ - format: 'Y-m-d H:i', + format: 'Y-m-d H:i:s', scrollInput:false, i18n: { lang: window.dateTimePickerTranslations @@ -106,8 +106,8 @@ function initDateTimeChooser(id) { language: 'lang' }); } else { - $('#' + id).datetimepicker({ - format: 'Y-m-d H:i', + $('#' + id).datetimepicker({ + format: 'Y-m-d H:i:s', }); } } diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 60de42698..1df870c38 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -286,8 +286,8 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Index show_in_menus = models.BooleanField(default=False, help_text=_("Whether a link to this page will appear in automatically generated menus")) search_description = models.TextField(blank=True) - go_live_at = models.DateTimeField(verbose_name=_("Go live date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) - expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) + go_live_at = models.DateTimeField(verbose_name=_("Go live date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm:ss."), blank=True, null=True) + expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm:ss."), blank=True, null=True) expired = models.BooleanField(default=False, editable=False) search_fields = ( From 1c146648e155455c49b2b1d4a795a9c996ade7e4 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 24 Jul 2014 09:32:51 +0100 Subject: [PATCH 47/62] Added failing test for #197 --- wagtail/tests/models.py | 11 ++++++ .../wagtailadmin/tests/test_pages_views.py | 35 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py index 15dd57eaa..e742c8295 100644 --- a/wagtail/tests/models.py +++ b/wagtail/tests/models.py @@ -4,7 +4,10 @@ from django.utils.encoding import python_2_unicode_compatible from django.conf.urls import url from django.http import HttpResponse +from taggit.models import TaggedItemBase + from modelcluster.fields import ParentalKey +from modelcluster.tags import ClusterTaggableManager from wagtail.wagtailcore.models import Page, Orderable from wagtail.wagtailcore.fields import RichTextField @@ -414,3 +417,11 @@ class RoutablePageTest(RoutablePage): def main(self, request): return HttpResponse("MAIN VIEW") + + +class TaggedPageTag(TaggedItemBase): + content_object = ParentalKey('tests.TaggedPage', related_name='tagged_items') + + +class TaggedPage(Page): + tags = ClusterTaggableManager(through=TaggedPageTag, blank=True) diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py index eeff0a6ed..d79837157 100644 --- a/wagtail/wagtailadmin/tests/test_pages_views.py +++ b/wagtail/wagtailadmin/tests/test_pages_views.py @@ -7,7 +7,7 @@ from django.core import mail from django.core.paginator import Paginator from django.utils import timezone -from wagtail.tests.models import SimplePage, EventPage, EventPageCarouselItem, StandardIndex, BusinessIndex, BusinessChild, BusinessSubIndex +from wagtail.tests.models import SimplePage, EventPage, EventPageCarouselItem, StandardIndex, BusinessIndex, BusinessChild, BusinessSubIndex, TaggedPage from wagtail.tests.utils import unittest, WagtailTestUtils from wagtail.wagtailcore.models import Page, PageRevision from wagtail.wagtailcore.signals import page_published, page_unpublished @@ -1410,3 +1410,36 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils): # No email to send self.assertEqual(len(mail.outbox), 0) + + +class TestIssue197(TestCase, WagtailTestUtils): + def test_issue_197(self): + # Find root page + self.root_page = Page.objects.get(id=2) + + # Create a tagged page with no tags + self.tagged_page = self.root_page.add_child(instance=TaggedPage( + title="Tagged page", + slug='tagged-page', + live=False, + )) + + # Login + self.user = self.login() + + # Add some tags and publish using edit view + post_data = { + 'title': "Tagged page", + 'slug':'tagged-page', + 'tags': "hello, world", + 'action-publish': "Publish", + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.tagged_page.id, )), post_data) + + # Should be redirected to explorer page + self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, ))) + + # Check that both tags are in the pages tag set + page = TaggedPage.objects.get(id=self.tagged_page.id) + self.assertIn('hello', page.tags.slugs()) + self.assertIn('world', page.tags.slugs()) From e857b630c8c18eddf0ade959e138870889b8713a Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 24 Jul 2014 11:03:08 +0100 Subject: [PATCH 48/62] Workaround a bug with django-modelcluster not committing m2m fields on form save. Fixes #192 --- wagtail/wagtailadmin/views/pages.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index 73b340854..ff09f860d 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -313,7 +313,13 @@ def edit(request, page_id): approved_go_live_at = go_live_at else: page.live = True - form.save() + + # We need save the page this way to workaround a bug + # in django-modelcluster causing m2m fields to not + # be committed to the database. See github issue #192 + form.save(commit=False) + page.save() + # Clear approved_go_live_at for older revisions page.revisions.update( submitted_for_moderation=False, @@ -328,7 +334,9 @@ def edit(request, page_id): Page.objects.filter(id=page.id).update(has_unpublished_changes=True) else: page.has_unpublished_changes = True - form.save() + form.save(commit=False) + page.save() + page.save_revision( user=request.user, From 0dbf00964dfa4149b4494dcc926047926d0f6ee9 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Thu, 24 Jul 2014 11:19:13 +0100 Subject: [PATCH 49/62] removed misleading greyed-out tick in checkboxes --- .../wagtailadmin/static/wagtailadmin/scss/components/forms.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss index d71ff66da..6b7a766b1 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss @@ -172,7 +172,7 @@ input[type=checkbox]:before{ height:20px; background-color:white; border:1px solid $color-grey-4; - color:$color-grey-4; + color:white; } input[type=checkbox]:checked:before{ color:$color-teal; From e89505de1f6ab2466238bf1c8d78552e3e146efe Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Thu, 24 Jul 2014 11:33:58 +0100 Subject: [PATCH 50/62] changelog / release notes for #500 --- CHANGELOG.txt | 2 ++ docs/releases/0.5.rst | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d7f66ec93..abb3c3ce8 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -9,6 +9,8 @@ Changelog * Replaced lxml dependency with html5lib, to simplify installation * Added page_unpublished signal + * Fix: Updates to tag fields are now properly committed to the database when publishing directly from the page edit interface + 0.4.1 (14.07.2014) ~~~~~~~~~~~~~~~~~~ * ElasticSearch backend now respects the backward-compatible URLS configuration setting, in addition to HOSTS diff --git a/docs/releases/0.5.rst b/docs/releases/0.5.rst index a0b52313e..7ee96adf2 100644 --- a/docs/releases/0.5.rst +++ b/docs/releases/0.5.rst @@ -49,6 +49,8 @@ Admin Bug fixes ~~~~~~~~~ + * Updates to tag fields are now properly committed to the database when publishing directly from the page edit interface. + Backwards incompatible changes ============================== From 5bd3ebc9d0e79ce21553aacdd65802999a2fe433 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Thu, 24 Jul 2014 11:40:01 +0100 Subject: [PATCH 51/62] added pagination to styleguide --- .../templates/wagtailstyleguide/base.html | 6 ++++++ wagtail/contrib/wagtailstyleguide/views.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html index 5877bb7ed..bcf48e830 100644 --- a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html +++ b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html @@ -24,6 +24,7 @@
            • Typography
            • Help text
            • Listings
            • +
            • Pagination
            • Buttons
            • Dropdown buttons
            • Header
            • @@ -175,6 +176,11 @@ +
              +

              Pagination

              + {% include "wagtailadmin/shared/pagination_nav.html" with items=fake_pagination linkurl="wagtailadmin_explore" %} +
              +

              Buttons

              diff --git a/wagtail/contrib/wagtailstyleguide/views.py b/wagtail/contrib/wagtailstyleguide/views.py index 9683289d2..927a17442 100644 --- a/wagtail/contrib/wagtailstyleguide/views.py +++ b/wagtail/contrib/wagtailstyleguide/views.py @@ -32,7 +32,20 @@ def index(request): messages.warning(request, _("Warning message")) messages.error(request, _("Error message")) + fake_pagination = { + 'number': 1, + 'previous_page_number': 1, + 'next_page_number': 2, + 'has_previous': True, + 'has_next': True, + 'paginator': { + 'num_pages': 10, + }, + } + + return render(request, 'wagtailstyleguide/base.html', { 'search_form': form, 'example_form': example_form, + 'fake_pagination': fake_pagination, }) From 118c3b0f33b863d5aa65b9272d758e9bd456e49e Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 24 Jul 2014 12:33:47 +0100 Subject: [PATCH 52/62] Changelog/release notes for multiple image uploader --- CHANGELOG.txt | 1 + docs/releases/0.5.rst | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index abb3c3ce8..579003000 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -3,6 +3,7 @@ Changelog 0.5 (xx.xx.20xx) ~~~~~~~~~~~~~~~~ + * Added multiple image uploader * Added RoutablePage model to allow embedding Django-style URL routing within a page * Explorer nav now rendered separately and fetched with AJAX when needed * Added decorator syntax for hooks diff --git a/docs/releases/0.5.rst b/docs/releases/0.5.rst index 7ee96adf2..76b0762de 100644 --- a/docs/releases/0.5.rst +++ b/docs/releases/0.5.rst @@ -10,6 +10,12 @@ Wagtail 0.5 release notes - IN DEVELOPMENT What's new ========== +Multiple image uploader +~~~~~~~~~~~~~~~~~~~~~~~ + +The image uploader UI has been improved to allow multiples to be uploaded quickly. + + RoutablePage ~~~~~~~~~~~~ From 7547e016613a1675676df9c729231f8a567ce7c5 Mon Sep 17 00:00:00 2001 From: Dave Cranwell Date: Thu, 24 Jul 2014 12:47:31 +0100 Subject: [PATCH 53/62] compressing multi-upload files --- .../templates/wagtailimages/multiple/add.html | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index 606bee94f..9945243d3 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -53,19 +53,19 @@ {% endblock %} {% block extra_js %} - - - - - - - - - - - - - {% include "wagtailadmin/shared/tag_field_js.html" %} + {% compress js %} + + + + + + + + + + + + {% endcompress %} {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}