Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
rsl committed Mar 28, 2008
0 parents commit 5ac1612
Show file tree
Hide file tree
Showing 10 changed files with 692 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
@@ -0,0 +1,3 @@
.DS_Store
doc
test/*.sqlite3
3 changes: 3 additions & 0 deletions CHANGELOG
@@ -0,0 +1,3 @@
= Version 0.9

Initial release
20 changes: 20 additions & 0 deletions MIT-LICENSE
@@ -0,0 +1,20 @@
Copyright (c) 2008 Lucky Sneaks

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
110 changes: 110 additions & 0 deletions README.rdoc
@@ -0,0 +1,110 @@
= ProxyAttributes

ProxyAttributes is designed to "skinny-up" your controller code by moving the creation and management of child associations to the parent object. It also has the side benefit of making it easier to use your association proxies directly within a form_for form.

Let's look at some examples and then I'll point out any features not salient from the examples, okay?

== Examples

=== In the Model

class Document < ActiveRecord::Base
has_many :categorizations
has_many :categories, :through => :categorizations
has_many :taggings
has_many :tags, :through => :taggings

validates_presence_of :title

proxy_attributes
# Will provide category_ids= method, in addition to add_category
by_ids :categories
# Will provide tags_as_string= method, in addition to add_tag
by_string :tags => :title
end
end

=== In the Controller

# With params == {
# :document => {
# :title => "Document Title",
# :tags_as_string => "simple, clean, elegant even",
# :category_ids => [8, 15],
# :add_category => {
# :title => "New Category"
# }
# }
# }

@document = Document.new(params[:document])
@document.save

# In that short code there, you've created a new Document [titled: "Document Title"]
# added three Tags [titled: "simple", "clean", and "elegant even"],
# associated them with the new document,
# created a new Category [titled: "New Category"],
# associated _it_ with the new document,
# and associated two pre-existing Categories [those with ids: 8 and 15] with the document.
# Not bad, eh?

== In the View

Maybe you're thinking all that simplicity comes at some serious expense in your views. Wrong!

<% form_for(@document) do |f| %>
<p>
<%= f.label :title %>
<%= f.text_field :title %>
</p>
<% unless @categories.empty? %>
<p>
<label>Check Categories</label>
<% @categories.each do |category| %>
<%= category.title %>
<%= proxy_attributes_check_box_tag :document, :category_ids, category %>
<% end %>
</p>
<% end %>
<p>
<% fields_for("document[add_category]", @document.add_category) do |ff| %>
<%= ff.label :title, "New Category (Title)" %>
<%= ff.text_field :title %>
<% end %>
</p>
<p>
<%= f.label :tags_as_string, "Tags" %>
<%= f.text_field :tags_as_string %>
</p>
<p>
<%= f.submit "Create" %>
</p>
<% end %>

A few notes on that view...

+proxy_attributes_check_box_tag+:: Read the docs[link:classes/LuckySneaks/ProxyAttributesFormHelpers.html#M000003]. It's really just that simple. Really.
+fields_for(html_name, actual_proxy_object)+:: Nothing really spectacular to note here either. Except that +@document.add_category+ returns a new Category just to please fields_for. Most of the time you should not be calling +add_child+ directly but using it with an attribute hash as shown in the example for the controller code.
+f.text_field :tags_as_string+:: Using the models in the example, this handy little method [internal to the model, not the view] is shorthand for @document.tags.map(&:title).join(", "). Hopefully comma-separated tags [or whatever] are everyone's bag. If not, please let me know and I'll see what i can do about accommodating your preferences. Or, better yet, just fork ProxyAttributes @ github, patch away, and gimme a pull request.

If you want multiple +add_child+ fields, simply add an index value to the fields_for arguments like so:

<p>
<% fields_for("document[add_category][#{index}]", @document.add_category[index]) do |ff| %>
<%= ff.label :title, "New Category (Title)" %>
<%= ff.text_field :title %>
<% end %>
</p>

And set it and forget it!

== But, but...

In order to avoided the dreaded ActiveRecord::HasManyThroughCantAssociateNewRecords exception, ProxyAttributes moves association creations to after_saves. This saves in a lot of frustration for most use cases I can think of but obviously causes a problem with models [in the child associations] that have many validations which can fail. The default settings for ProxyAttributes is to simply swallow child validation errors and either not create the new child or not save the invalid changes. This behavior can be overridden with the +dont_swallow_errors!+ directive inside the +proxy_attributes+ block which will raise LuckySneaks::ProxyAttributes::InvalidChildAssignment. _*You*_ are responsible for rescuing this exception in your controller. There's no way to cause the parent model [which has already passed validation and been saved] to be invalid. Instead, errors are added to +:proxy_attribute_child_errors+ if you want to parse that for your error messages.

== Todo

* At the moment, only +has_many :through+ associations are handled. The code is in place to handle regular +has_many+ associations but [to be frank] I am pressed for time at the moment and don't need +has_many+ support in the project I am using this code in.
* Because

Copyright (c) 2008 Lucky Sneaks, released under the MIT license
27 changes: 27 additions & 0 deletions Rakefile
@@ -0,0 +1,27 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'

desc 'Default: run unit tests.'
task :default => [:refresh_db, :test]

desc 'Remove old sqlite file'
task :refresh_db do
`rm -f #{File.dirname(__FILE__)}/test/proxy_attributes.sqlite3`
end

desc 'Test the ProxyAttributes plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end

desc 'Generate documentation for the ProxyAttributes plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'doc'
rdoc.title = 'ProxyAttributes'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
8 changes: 8 additions & 0 deletions init.rb
@@ -0,0 +1,8 @@
require "lucky_sneaks/proxy_integrator"
require "lucky_sneaks/proxy_attributes"
ActiveRecord::Base.send :include, LuckySneaks::ProxyAttributes

if defined?(ActionView)
require "lucky_sneaks/proxy_attributes_form_helpers"
ActionView::Base.send :include, LuckySneaks::ProxyAttributesFormHelpers
end
137 changes: 137 additions & 0 deletions lib/lucky_sneaks/proxy_attributes.rb
@@ -0,0 +1,137 @@
module LuckySneaks
module ProxyAttributes
def self.included(base) # :nodoc:
base.extend ClassMethods
base.send :include, InstanceMethods
end

module ClassMethods
# This is the meat of everything
#
# TODO: More documentation
#
# Note: By default, invalid child records are simply discarded.
# If a child record has already been saved, invalid changes will not be saved.
# ProxyAttributes was designed for simplifying form inputs and
# works best in cases where invalid children can be ignored.
# If you require validation for your children, you can use
# <tt>dont_swallow_errors!</tt> within your <tt>proxy_attributes</tt>
# block to raise LuckySneaks::ProxyAttributes::InvalidChildAssignment.
# *You* are responsible for catching this exception in your controllers.
# There will be an error on base noting what exactly failed.
def proxy_attributes(&block)
cattr_accessor :attributes_for_string, :dont_swallow_errors
self.attributes_for_string = {}.with_indifferent_access

integrator = LuckySneaks::ProxyIntegrator.new(self)
integrator.instance_eval(&block)

after_save :assign_postponed
end
end

module InstanceMethods
# Holds assignment hashes postponed for after_save
# when the parent object is a new record.
# This is really meant for use internally
# but might come in handy if you need to examine if there
# are postponed assignments elsewhere in your code.
def postponed
@postponed ||= {}
end

private
def assign_postponed
postponed.each do |association_id, assignment|
assign_or_postpone association_id => assignment
end
unless postponed_errors.blank?
errors.add :proxy_attribute_child_errors, postponed_errors.flatten!
raise LuckySneaks::ProxyAttributes::InvalidChildAssignment
end
end

def assign_or_postpone(assignment_hash)
if new_record?
postponed.merge! assignment_hash
else
assignment_hash.each do |association_id, assignment|
if association_id =~ /_ids$/
assignment.delete 0
return if assignment == self.send("#{association_id}_without_postponed")
assign_proxy_by_ids association_id, assignment
elsif association_id =~ /_as_string$/
return if assignment == self.send("#{association_id}_without_postponed")
assign_proxy_by_string association_id, assignment
elsif association_id =~ /^add_/
return if assignment.values.all?{|v| v.blank?}
if assignment.values.first.is_a?(Hash)
assignment.each do |index, actual_assignment|
next if actual_assignment.values.all?{|v| v.blank?}
create_proxy association_id, actual_assignment
end
else
create_proxy association_id, assignment
end
end
end
end
end

def assign_proxy_by_ids(association_id, array_of_ids)
proxy = fetch_proxy(association_id.chomp("_ids"))

self.send(proxy.through_reflection.name).clear
self.send(proxy.name) << proxy.klass.find(array_of_ids)
end

def assign_proxy_by_string(association_id, string)
association_id = association_id.chomp("_as_string")
proxy = fetch_proxy(association_id)
attribute = self.class.attributes_for_string[association_id.to_sym]

self.send(proxy.through_reflection.name).clear
self.send(proxy.name) << string.split(/,\s*/).map { |substring|
next if substring.blank?
member = proxy.klass.send("find_or_initialize_by_#{attribute}", substring)
# Only return valid, saved members
if member.save
member
else
postpone_errors member
# Don't add this member!
nil
end
}.uniq.compact
end

def create_proxy(association_id, hash_of_attributes)
proxy = fetch_proxy(association_id.sub(/add_/, ""))

member = proxy.klass.new(hash_of_attributes)
if member.save
self.send(proxy.name) << member
else
postpone_errors member
end
end

def fetch_proxy(association_id)
self.class.reflect_on_association(association_id.pluralize.to_sym)
end

def postpone_errors(member)
return unless self.class.dont_swallow_errors
postponed_errors << member.errors.full_messages.map {|message|
"#{member} could not be saved because: #{message}"
}
end

def postponed_errors
@postponed_errors ||= []
end
end

class InvalidChildAssignment < StandardError; end
end
end
37 changes: 37 additions & 0 deletions lib/lucky_sneaks/proxy_attributes_form_helpers.rb
@@ -0,0 +1,37 @@
module LuckySneaks
module ProxyAttributesFormHelpers
# Simply a shortcut for the somewhat standard check_box_tag used for
# <tt>has_and_belongs_to_many</tt> checkboxes popularized by ryan bates'
# railscasts[http://railscasts.com/episodes/17]
#
# The following examples are equivalent
#
# proxy_attributes_check_box_tag :document, :category_ids, category
#
# check_box_tag "document[category_ids][]", category.id, @document.category_ids.include?(category.id)
#
# In addition, proxy_attributes_check_box_tag will add a hidden input tag with
# with value of 0 in order to send the params when no value is checked.
#
# PS: If you have a better/shorter name for this, I'm all ears. :)
def proxy_attributes_check_box_tag(form_object_name, proxy_name, object_to_check)
method_name = "#{form_object_name}[#{proxy_name}][]"
checked_value = instance_variable_get("@#{form_object_name}").send(proxy_name).include?(object_to_check.id)
tag = check_box_tag method_name, object_to_check.id, checked_value
if previous_check_box_exists_for[method_name]
check_box_tag method_name, object_to_check.id, checked_value
else
previous_check_box_for[method_name] = true
[
hidden_field_tag(method_name, 0),
check_box_tag(method_name, object_to_check.id, checked_value)
].join("\n")
end
end

private
def previous_check_box_exists_for
@proxy_attributes_check_box_mapping ||= {}
end
end
end

0 comments on commit 5ac1612

Please sign in to comment.