Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
rsl
committed
Mar 28, 2008
0 parents
commit 5ac1612
Showing
10 changed files
with
692 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.DS_Store | ||
doc | ||
test/*.sqlite3 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
= Version 0.9 | ||
|
||
Initial release |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.