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.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..adde6853d 100644
--- a/app/assets/javascripts/views/note_form.js
+++ b/app/assets/javascripts/views/note_form.js
@@ -19,14 +19,23 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({
},
events: {
- "click input": "saveEdit"
+ "click .note-submit": "saveEdit"
},
-
+
saveEdit: function() {
+ var fileInput = $(this.el).find('.note-attachment')[0];
+ var fileObject = fileInput && fileInput.files[0];
+ if (fileObject !== undefined) {
+ this.model.set('attachment', fileObject);
+ $(this.el).append('
5%
');
+ }
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});
},
@@ -40,6 +49,7 @@ Fulcrum.NoteForm = Fulcrum.FormView.extend({
});
}
});
+ this.model.on('progress', _.bind(this._handleProgress, this));
},
render: function() {
@@ -48,9 +58,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);
@@ -67,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)+"%");
+ }
}
});
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..72f49d218 100644
--- a/spec/features/notes_spec.rb
+++ b/spec/features/notes_spec.rb
@@ -33,10 +33,12 @@
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
+ 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/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/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
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;
+
+}));