From f87c1da3e767302eea64d784029f27d1c743ca28 Mon Sep 17 00:00:00 2001 From: Valery Kalashnikov Date: Wed, 22 Apr 2015 12:58:16 +0600 Subject: [PATCH 1/4] Add attachment to note implemented --- .gitignore | 1 + Gemfile | 2 + Gemfile.lock | 6 + app/assets/javascripts/application.js | 1 + app/assets/javascripts/models/note.js | 16 ++ app/assets/javascripts/templates/note.jst.ejs | 5 + app/assets/javascripts/views/note_form.js | 17 +- app/assets/stylesheets/screen.css.scss | 9 + app/controllers/notes_controller.rb | 4 +- app/models/note.rb | 5 +- app/uploaders/note_attachment_uploader.rb | 51 ++++++ .../20150421074925_add_attachment_to_note.rb | 5 + db/schema.rb | 8 +- spec/features/notes_spec.rb | 2 +- spec/models/note_spec.rb | 2 +- .../javascripts/backbone-model-file-upload.js | 173 ++++++++++++++++++ 16 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 app/uploaders/note_attachment_uploader.rb create mode 100644 db/migrate/20150421074925_add_attachment_to_note.rb create mode 100755 vendor/assets/javascripts/backbone-model-file-upload.js diff --git a/.gitignore b/.gitignore index 8fdfe6632..9b28027ca 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tmp/ *.DS_Store config/deploy.rb *.swp +public/uploads diff --git a/Gemfile b/Gemfile index 1b56dd20f..fc633d3a6 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,8 @@ gem 'pg', group: :postgres gem 'mysql2', group: :mysql gem 'sqlite3', group: :sqlite +gem 'carrierwave' + group :production do gem 'rails_12factor' end diff --git a/Gemfile.lock b/Gemfile.lock index 1e4d10db2..9f913796e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,6 +37,11 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) + carrierwave (0.10.0) + activemodel (>= 3.2.0) + activesupport (>= 3.2.0) + json (>= 1.7) + mime-types (>= 1.16) childprocess (0.5.5) ffi (~> 1.0, >= 1.0.11) chunky_png (1.3.4) @@ -210,6 +215,7 @@ PLATFORMS DEPENDENCIES capybara + carrierwave compass-rails configuration database_cleaner diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index be81fa3ea..628a5bec0 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,6 +10,7 @@ //= require underscore //= require backbone //= require backbone.rails +//= require backbone-model-file-upload //= require Markdown.Converter //= require_tree ./templates //= require_tree ./mixins diff --git a/app/assets/javascripts/models/note.js b/app/assets/javascripts/models/note.js index ec488b2e9..e6478df3a 100644 --- a/app/assets/javascripts/models/note.js +++ b/app/assets/javascripts/models/note.js @@ -6,6 +6,8 @@ Fulcrum.Note = Backbone.Model.extend({ name: 'note', + fileAttribute: 'attachment', + i18nScope: 'activerecord.attributes.note', user: function() { @@ -13,6 +15,20 @@ Fulcrum.Note = Backbone.Model.extend({ return this.collection.story.collection.project.users.get(userId); }, + attachmentUrl: function() { + var attachment = this.get('attachment'); + + if (attachment && attachment.url !== null) { + return attachment.url + } + return ""; + }, + + attachmentFileName: function() { + var attachmentUrl = this.attachmentUrl(); + return _.last(attachmentUrl.split('/')); + }, + userName: function() { var user = this.user(); return user ? user.get('name') : 'Author unknown'; diff --git a/app/assets/javascripts/templates/note.jst.ejs b/app/assets/javascripts/templates/note.jst.ejs index 5d885a069..ce96152ff 100644 --- a/app/assets/javascripts/templates/note.jst.ejs +++ b/app/assets/javascripts/templates/note.jst.ejs @@ -1,4 +1,9 @@ <%= window.md.makeHtml(note.escape("note")) %> +<% if (note.attachmentUrl() != "" ) { %> +
+ <%= note.attachmentFileName() %> +
+<% } %>
<%= note.userName() %> - <%= note.get("created_at") %> - diff --git a/app/assets/javascripts/views/note_form.js b/app/assets/javascripts/views/note_form.js index 6bd8e3dbb..efbc39e38 100644 --- a/app/assets/javascripts/views/note_form.js +++ b/app/assets/javascripts/views/note_form.js @@ -19,14 +19,21 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ }, events: { - "click input": "saveEdit" + "click .note-submit": "saveEdit" }, - + saveEdit: function() { + var fileObject = $(this.el).find('.note-attachment')[0].files[0]; + if (fileObject !== undefined) { + this.model.set('attachment', fileObject); + } this.disableForm(); var view = this; this.model.save(null, { + // therefore is not possible to send file object as a part of JSON, + // it is better to always save backbone model info as a form object + formData: true, success: function(model, response) { //view.model.set({editing: false}); }, @@ -48,9 +55,11 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ div = this.make('div'); $(div).append(this.label("note")); $(div).append('
'); - $(div).append(this.textArea("note")); + $(div).append(this.textArea("note[note]")); - var submit = this.make('input', {id: 'note_submit', type: 'button', value: 'Add note'}); + var attachment = this.make('input', {id:'note_attachment', class:'note-attachment', name: 'attachment', type: 'file'}) + var submit = this.make('input', {id: 'note_submit', class:'note-submit', type: 'button', value: 'Add note'}); + $(div).append(attachment); $(div).append(submit); this.$el.html(div); diff --git a/app/assets/stylesheets/screen.css.scss b/app/assets/stylesheets/screen.css.scss index 19399a738..ea77e48f2 100644 --- a/app/assets/stylesheets/screen.css.scss +++ b/app/assets/stylesheets/screen.css.scss @@ -539,6 +539,11 @@ div.note { padding: 0.3em; } + .file a { + display: inline-block; + padding: 0 5px; + } + div.note_meta { color: $aluminium-4; font-style: italic; @@ -554,6 +559,10 @@ div.note { .note_form { // The submit button while the server is saving the note. + .note-attachment { + margin: 10px 0; + } + input.saving { padding-left: 16px; background-image: url('throbber.gif'); diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb index 877dd37a4..1af1d85fe 100644 --- a/app/controllers/notes_controller.rb +++ b/app/controllers/notes_controller.rb @@ -37,7 +37,9 @@ def create protected def allowed_params - params.fetch(:note).permit(:note) + _params = params.fetch(:note, {}).permit(:note) + _params = _params.merge(attachment: params[:attachment]) if params[:attachment].present? + _params end end diff --git a/app/models/note.rb b/app/models/note.rb index 17839c059..77f92de57 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -1,10 +1,13 @@ class Note < ActiveRecord::Base + belongs_to :user belongs_to :story after_save :create_changeset - validates :note, :presence => true + validates :note, presence: true, if: "attachment.blank?" + + mount_uploader :attachment, NoteAttachmentUploader # FIXME move to observer def create_changeset diff --git a/app/uploaders/note_attachment_uploader.rb b/app/uploaders/note_attachment_uploader.rb new file mode 100644 index 000000000..d328eeff0 --- /dev/null +++ b/app/uploaders/note_attachment_uploader.rb @@ -0,0 +1,51 @@ +# encoding: utf-8 + +class NoteAttachmentUploader < CarrierWave::Uploader::Base + + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + # include CarrierWave::MiniMagick + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + # def extension_white_list + # %w(jpg jpeg gif png) + # end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + # def filename + # "something.jpg" if original_filename + # end + +end diff --git a/db/migrate/20150421074925_add_attachment_to_note.rb b/db/migrate/20150421074925_add_attachment_to_note.rb new file mode 100644 index 000000000..37d9cf102 --- /dev/null +++ b/db/migrate/20150421074925_add_attachment_to_note.rb @@ -0,0 +1,5 @@ +class AddAttachmentToNote < ActiveRecord::Migration + def change + add_column :notes, :attachment, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d8f3a68f..4281ee459 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20120504152649) do +ActiveRecord::Schema.define(version: 20150421074925) do create_table "changesets", force: true do |t| t.integer "story_id" @@ -26,6 +26,7 @@ t.integer "story_id" t.datetime "created_at" t.datetime "updated_at" + t.string "attachment" end create_table "projects", force: true do |t| @@ -60,6 +61,11 @@ t.string "labels" end + create_table "story_attachments", force: true do |t| + t.string "attachment" + t.integer "story_id" + end + create_table "users", force: true do |t| t.string "email", default: "", null: false t.string "encrypted_password", limit: 128, default: "", null: false diff --git a/spec/features/notes_spec.rb b/spec/features/notes_spec.rb index 188df7c6f..952d7f01b 100644 --- a/spec/features/notes_spec.rb +++ b/spec/features/notes_spec.rb @@ -33,7 +33,7 @@ within('#in_progress .story') do find('.story-title').click - fill_in 'note', :with => 'Adding a new note' + fill_in 'note[note]', :with => 'Adding a new note' click_on 'Add note' end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 89104f0eb..34ccff022 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -57,7 +57,7 @@ it "returns the right keys" do subject.as_json["note"].keys.sort.should == %w[ - created_at errors id note story_id updated_at user_id + attachment created_at errors id note story_id updated_at user_id ] end diff --git a/vendor/assets/javascripts/backbone-model-file-upload.js b/vendor/assets/javascripts/backbone-model-file-upload.js new file mode 100755 index 000000000..3b0daac5d --- /dev/null +++ b/vendor/assets/javascripts/backbone-model-file-upload.js @@ -0,0 +1,173 @@ +// Backbone.Model File Upload v1.0.0 +// by Joe Vu - joe.vu@homeslicesolutions.com +// For all details and documentation: +// https://github.com/homeslicesolutions/backbone-model-file-upload +// Contributors: +// lutherism - Alex Jansen - alex.openrobot.net +// bildja - Dima Bildin - github.com/bildja +// Minjung - Alejandro - github.com/Minjung +// XemsDoom - Luca Moser - https://github.com/XemsDoom +// DanilloCorvalan - Danillo Corvalan - https://github.com/DanilloCorvalan + +(function(root, factory) { + + // AMD + if (typeof define === 'function' && define.amd) { + define(['underscore', 'jquery', 'backbone'], function(_, $, Backbone){ + factory(root, Backbone, _, $); + }); + + // NodeJS/CommonJS + } else if (typeof exports !== 'undefined') { + var _ = require('underscore'), $ = require('jquery'), Backbone = require('backbone'); + factory(root, Backbone, _, $); + + // Browser global + } else { + factory(root, root.Backbone, root._, root.$); + } + +}(this, function(root, Backbone, _, $) { + 'use strict'; + + // Clone the original Backbone.Model.prototype as superClass + var _superClass = _.clone( Backbone.Model.prototype ); + + // Extending out + var BackboneModelFileUpload = Backbone.Model.extend({ + + // ! Default file attribute - can be overwritten + fileAttribute: 'file', + + // @ Save - overwritten + save: function(key, val, options) { + + // Variables + var attrs, attributes = this.attributes; + + // Signature parsing - taken directly from original Backbone.Model.save + // and it states: 'Handle both "key", value and {key: value} -style arguments.' + if (key == null || typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + // Validate & wait options - taken directly from original Backbone.Model.save + options = _.extend({validate: true}, options); + if (attrs && !options.wait) { + if (!this.set(attrs, options)) return false; + } else { + if (!this._validate(attrs, options)) return false; + } + + // Merge data temporarily for formdata + var mergedAttrs = _.extend({}, attributes, attrs); + + if (attrs && options.wait) { + this.attributes = mergedAttrs; + } + + // Check for "formData" flag and check for if file exist. + if ( options.formData === true + || options.formData !== false + && mergedAttrs[ this.fileAttribute ] + && mergedAttrs[ this.fileAttribute ] instanceof File + || mergedAttrs[ this.fileAttribute ] instanceof FileList + || mergedAttrs[ this.fileAttribute ] instanceof Blob ) { + + // Flatten Attributes reapplying File Object + var formAttrs = _.clone( mergedAttrs ), + fileAttr = mergedAttrs[ this.fileAttribute ]; + formAttrs = this._flatten( formAttrs ); + formAttrs[ this.fileAttribute ] = fileAttr; + + // Converting Attributes to Form Data + var formData = new FormData(); + _.each( formAttrs, function( value, key ){ + if (value instanceof FileList) { + _.each(value, function(file) { + formData.append( key, file ); + }); + return; + } + formData.append( key, value ); + }); + + // Set options for AJAX call + options.data = formData; + options.processData = false; + options.contentType = false; + + // Apply custom XHR for processing status & listen to "progress" + var that = this; + options.xhr = function() { + var xhr = $.ajaxSettings.xhr(); + xhr.upload.addEventListener('progress', that._progressHandler.bind(that), false); + return xhr; + } + } + + // Resume back to original state + if (attrs && options.wait) this.attributes = attributes; + + // Continue to call the existing "save" method + return _superClass.save.call(this, attrs, options); + + }, + + // _ FlattenObject gist by "penguinboy". Thank You! + // https://gist.github.com/penguinboy/762197 + // NOTE for those who use "<1.0.0". The notation changed to nested brackets + _flatten: function flatten( obj ) { + var output = {}; + for (var i in obj) { + if (!obj.hasOwnProperty(i)) continue; + if (typeof obj[i] == 'object') { + var flatObject = flatten(obj[i]); + for (var x in flatObject) { + if (!flatObject.hasOwnProperty(x)) continue; + output[i + '[' + x + ']'] = flatObject[x]; + } + } else { + output[i] = obj[i]; + } + } + return output; + + }, + + // An "Unflatten" tool which is something normally should be on the backend + // But this is a guide to how you would unflatten the object + _unflatten: function unflatten(obj, output) { + var re = /^([^\[\]]+)\[(.+)\]$/g; + output = output || {}; + for (var key in obj) { + var value = obj[key]; + if (!key.toString().match(re)) { + var tempOut = {}; + tempOut[key] = value; + _.extend(output, tempOut); + } else { + var keys = _.compact(key.split(re)), tempOut = {}; + tempOut[keys[1]] = value; + output[keys[0]] = unflatten( tempOut, output[keys[0]] ) + } + } + return output; + }, + + // _ Get the Progress of the uploading file + _progressHandler: function( event ) { + if (event.lengthComputable) { + var percentComplete = event.loaded / event.total; + this.trigger( 'progress', percentComplete ); + } + } + }); + + // Export out to override Backbone Model + Backbone.Model = BackboneModelFileUpload; + +})); From a4c2c2442fccfac4badf53f201afe89ecca74b7b Mon Sep 17 00:00:00 2001 From: Valery Kalashnikov Date: Wed, 22 Apr 2015 13:52:42 +0600 Subject: [PATCH 2/4] Fixed getting fileObject to pass jasmine specs --- app/assets/javascripts/views/note_form.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/views/note_form.js b/app/assets/javascripts/views/note_form.js index efbc39e38..d079238cf 100644 --- a/app/assets/javascripts/views/note_form.js +++ b/app/assets/javascripts/views/note_form.js @@ -23,7 +23,8 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ }, saveEdit: function() { - var fileObject = $(this.el).find('.note-attachment')[0].files[0]; + var fileInput = $(this.el).find('.note-attachment')[0]; + var fileObject = fileInput && fileInput.files[0]; if (fileObject !== undefined) { this.model.set('attachment', fileObject); } From c763d37d73efe91d8c14a972e62a6eef1c50ca01 Mon Sep 17 00:00:00 2001 From: Valery Kalashnikov Date: Wed, 22 Apr 2015 15:58:55 +0600 Subject: [PATCH 3/4] Added wait_for_ajax_method Sometimes Capybara.default_wait_time is not enough and build fails. --- spec/features/notes_spec.rb | 5 +++++ spec/features/stories_spec.rb | 2 ++ spec/support/integration_helpers.rb | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/spec/features/notes_spec.rb b/spec/features/notes_spec.rb index 952d7f01b..72f49d218 100644 --- a/spec/features/notes_spec.rb +++ b/spec/features/notes_spec.rb @@ -37,6 +37,8 @@ click_on 'Add note' end + wait_for_ajax + find('#in_progress .story .notelist .note').should have_content('Adding a new note') end @@ -54,6 +56,9 @@ click_on 'Delete' end end + + wait_for_ajax + find('#in_progress .story .notelist').should_not have_content('Delete me please') end diff --git a/spec/features/stories_spec.rb b/spec/features/stories_spec.rb index 6c05f857d..371d51d94 100644 --- a/spec/features/stories_spec.rb +++ b/spec/features/stories_spec.rb @@ -32,6 +32,8 @@ click_on 'Save' end + wait_for_ajax + # Estimate the story within('#chilly_bin .story') do click_on '1' diff --git a/spec/support/integration_helpers.rb b/spec/support/integration_helpers.rb index 54dd6e171..fcd9a47b9 100644 --- a/spec/support/integration_helpers.rb +++ b/spec/support/integration_helpers.rb @@ -40,4 +40,13 @@ def get_confirmation_token_from_mail(email) token end + def wait_for_ajax + Timeout.timeout(Capybara.default_wait_time) do + active = page.evaluate_script('jQuery.active') + until active == 0 + active = page.evaluate_script('jQuery.active') + end + end + end + end From 58f8db8a14af924133e8dca7450063285998bb7b Mon Sep 17 00:00:00 2001 From: Valery Kalashnikov Date: Fri, 24 Apr 2015 11:28:15 +0600 Subject: [PATCH 4/4] File upload progress added --- app/assets/javascripts/views/note_form.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/assets/javascripts/views/note_form.js b/app/assets/javascripts/views/note_form.js index d079238cf..adde6853d 100644 --- a/app/assets/javascripts/views/note_form.js +++ b/app/assets/javascripts/views/note_form.js @@ -27,6 +27,7 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ var fileObject = fileInput && fileInput.files[0]; if (fileObject !== undefined) { this.model.set('attachment', fileObject); + $(this.el).append('
5%
'); } this.disableForm(); @@ -48,6 +49,7 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ }); } }); + this.model.on('progress', _.bind(this._handleProgress, this)); }, render: function() { @@ -77,5 +79,12 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({ enableForm: function() { this.$('input,textarea').removeAttr('disabled'); this.$('input[type="button"]').removeClass('saving'); + }, + + _handleProgress: function(value) { + var progressValue = this.$el.find('.progress-value'); + if (progressValue.length !== 0) { + progressValue.html(parseInt(value * 100)+"%"); + } } });