reform 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. data/.gitignore +1 -0
  2. data/.travis.yml +0 -1
  3. data/CHANGES.md +13 -0
  4. data/Gemfile +0 -2
  5. data/README.md +276 -94
  6. data/Rakefile +6 -0
  7. data/TODO.md +10 -1
  8. data/database.sqlite3 +0 -0
  9. data/lib/reform/active_record.rb +2 -0
  10. data/lib/reform/composition.rb +55 -0
  11. data/lib/reform/form/active_model.rb +60 -15
  12. data/lib/reform/form/active_record.rb +3 -3
  13. data/lib/reform/form/composition.rb +69 -0
  14. data/lib/reform/form.rb +183 -80
  15. data/lib/reform/rails.rb +8 -1
  16. data/lib/reform/representer.rb +38 -0
  17. data/lib/reform/version.rb +1 -1
  18. data/lib/reform.rb +5 -2
  19. data/reform.gemspec +3 -2
  20. data/test/active_model_test.rb +83 -9
  21. data/test/coercion_test.rb +26 -0
  22. data/test/composition_test.rb +57 -0
  23. data/test/dummy/Rakefile +7 -0
  24. data/test/dummy/app/controllers/albums_controller.rb +18 -0
  25. data/test/dummy/app/controllers/application_controller.rb +4 -0
  26. data/test/dummy/app/controllers/musician_controller.rb +5 -0
  27. data/test/dummy/app/forms/album_form.rb +18 -0
  28. data/test/dummy/app/helpers/application_helper.rb +2 -0
  29. data/test/dummy/app/models/album.rb +4 -0
  30. data/test/dummy/app/models/song.rb +3 -0
  31. data/test/dummy/app/views/albums/new.html.erb +28 -0
  32. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  33. data/test/dummy/config/application.rb +20 -0
  34. data/test/dummy/config/boot.rb +10 -0
  35. data/test/dummy/config/database.yml +22 -0
  36. data/test/dummy/config/environment.rb +5 -0
  37. data/test/dummy/config/environments/development.rb +16 -0
  38. data/test/dummy/config/environments/production.rb +46 -0
  39. data/test/dummy/config/environments/test.rb +33 -0
  40. data/test/dummy/config/locales/en.yml +5 -0
  41. data/test/dummy/config/routes.rb +4 -0
  42. data/test/dummy/config.ru +4 -0
  43. data/test/dummy/db/test.sqlite3 +0 -0
  44. data/test/dummy/log/production.log +0 -0
  45. data/test/dummy/log/server.log +0 -0
  46. data/test/errors_test.rb +95 -0
  47. data/test/form_composition_test.rb +60 -0
  48. data/test/nested_form_test.rb +129 -0
  49. data/test/rails/integration_test.rb +54 -0
  50. data/test/reform_test.rb +80 -114
  51. data/test/test_helper.rb +14 -1
  52. metadata +86 -11
  53. data/lib/reform/form/dsl.rb +0 -38
  54. data/test/dsl_test.rb +0 -43
@@ -1,31 +1,76 @@
1
1
  module Reform::Form::ActiveModel
2
+ module FormBuilderMethods # TODO: rename to FormBuilderCompat.
3
+ def self.included(base)
4
+ base.class_eval do
5
+ extend ClassMethods # ::model_name
6
+ end
7
+ end
8
+
9
+ module ClassMethods
10
+ def property(name, options={})
11
+ add_nested_attribute_compat(name) if block_given? # TODO: fix that in Rails FB#1832 work.
12
+ super
13
+ end
14
+
15
+ private
16
+ # The Rails FormBuilder "detects" nested attributes (which is what we want) by checking existance of a setter method.
17
+ def add_nested_attribute_compat(name)
18
+ define_method("#{name}_attributes=") {} # this is why i hate respond_to? in Rails.
19
+ end
20
+ end
21
+
22
+ # Modify the incoming Rails params hash to be representable compliant.
23
+ def validate(params)
24
+ # DISCUSS: #validate should actually expect the complete params hash and then pick the right key as it knows the form name.
25
+ # however, this would cause confusion?
26
+ mapper.new(self).nested_forms do |attr, model| # FIXME: make this simpler.
27
+ if attr.options[:form_collection] # FIXME: why no array?
28
+ params[attr.name] = params["#{attr.name}_attributes"].values
29
+ else
30
+ params[attr.name] = params["#{attr.name}_attributes"]# DISCUSS: delete old key? override existing?
31
+ end
32
+ end
33
+
34
+ super
35
+ end
36
+ end
37
+
38
+
2
39
  def self.included(base)
3
40
  base.class_eval do
4
41
  extend ClassMethods
42
+
43
+ delegate [:persisted?, :to_key, :to_param, :id] => :model
44
+
45
+ def to_model # this is called somewhere in FormBuilder and ActionController.
46
+ self
47
+ end
5
48
  end
6
49
  end
7
50
 
8
51
  module ClassMethods
52
+ # Set a model name for this form if the infered is wrong.
53
+ #
54
+ # class CoverSongForm < Reform::Form
55
+ # model :song
9
56
  def model(main_model, options={})
10
- @model_options = [main_model, options] # FIXME: make inheritable!
11
- composition_model = options[:on] || main_model
12
-
13
- delegate composition_model, :to => :model # #song => model.song
14
- delegate :persisted?, :to_key, :to_param, :to_model, :to => composition_model # #to_key => song.to_key
15
-
16
- alias_method main_model, composition_model # #hit => model.song.
17
- end
18
-
19
- def property(name, options={})
20
- delegate options[:on], :to => :model
21
- super
57
+ @model_options = [main_model, options] # FIXME: make inheritable!
22
58
  end
23
59
 
24
60
  def model_name
25
- name = @model_options.first.to_s.camelize
61
+ if @model_options
62
+ form_name = @model_options.first.to_s.camelize
63
+ else
64
+ form_name = name.sub(/Form$/, "")
65
+ end
66
+
67
+ active_model_name_for(form_name)
68
+ end
26
69
 
27
- return ::ActiveModel::Name.new(OpenStruct.new(:name => name)) if ::ActiveModel::VERSION::MAJOR == 3 and ::ActiveModel::VERSION::MINOR == 0
28
- ::ActiveModel::Name.new(self, nil, name)
70
+ private
71
+ def active_model_name_for(string)
72
+ return ::ActiveModel::Name.new(OpenStruct.new(:name => string)) if Reform.rails3_0?
73
+ ::ActiveModel::Name.new(self, nil, string)
29
74
  end
30
75
  end
31
76
  end
@@ -2,7 +2,7 @@ class Reform::Form
2
2
  module ActiveRecord
3
3
  def self.included(base)
4
4
  base.class_eval do
5
- include ActiveModel
5
+ include Reform::Form::ActiveModel
6
6
  extend ClassMethods
7
7
  end
8
8
  end
@@ -17,12 +17,12 @@ class Reform::Form
17
17
  # when calling validates it should create the Vali instance already and set @klass there! # TODO: fix this in AM.
18
18
  def validate(form)
19
19
  property = attributes.first
20
- model_name = form.send(:model).class.model_for_property(property)
20
+ #model_name = form.send(:model).class.model_for_property(property)
21
21
 
22
22
  # here is the thing: why does AM::UniquenessValidator require a filled-out record to work properly? also, why do we need to set
23
23
  # the class? it would be way easier to pass #validate a hash of attributes and get back an errors hash.
24
24
  # the class for the finder could either be infered from the record or set in the validator instance itself in the call to ::validates.
25
- record = form.send(model_name)
25
+ record = form.send(:model)
26
26
  record.send("#{property}=", form.send(property))
27
27
  @klass = record.class # this is usually done in the super-sucky #setup method.
28
28
  super(record).tap do |res|
@@ -0,0 +1,69 @@
1
+ require "reform/form/active_model"
2
+
3
+ class Reform::Form
4
+ # Automatically creates a Composition object for you when initializing the form.
5
+ module Composition
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend ClassMethods
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ include Reform::Form::ActiveModel::ClassMethods # ::model.
14
+
15
+ def model_class # DISCUSS: needed?
16
+ rpr = representer_class
17
+ @model_class ||= Class.new(Reform::Composition) do
18
+ map_from rpr
19
+ end
20
+ end
21
+
22
+ def property(name, options={})
23
+ super
24
+ delegate options[:on] => :@model
25
+ end
26
+
27
+ # Same as ActiveModel::model but allows you to define the main model in the composition
28
+ # using +:on+.
29
+ #
30
+ # class CoverSongForm < Reform::Form
31
+ # model :song, on: :cover_song
32
+ def model(main_model, options={})
33
+ super
34
+
35
+ composition_model = options[:on] || main_model
36
+
37
+ delegate composition_model => :model # #song => model.song
38
+
39
+ # FIXME: this should just delegate to :model as in FB, and the comp would take care of it internally.
40
+ delegate [:persisted?, :to_key, :to_param] => composition_model # #to_key => song.to_key
41
+
42
+ alias_method main_model, composition_model # #hit => model.song.
43
+ end
44
+ end
45
+
46
+ def initialize(models)
47
+ composition = self.class.model_class.new(models)
48
+ super(composition)
49
+ end
50
+
51
+ def to_nested_hash
52
+ model.nested_hash_for(to_hash) # use composition to compute nested hash.
53
+ end
54
+ end
55
+
56
+
57
+ # TODO: remove me in 1.3.
58
+ module DSL
59
+ include Composition
60
+
61
+ def self.included(base)
62
+ warn "[DEPRECATION] Reform::Form: `DSL` is deprecated. Please use `Composition` instead."
63
+
64
+ base.class_eval do
65
+ extend Composition::ClassMethods
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/reform/form.rb CHANGED
@@ -1,143 +1,246 @@
1
- require 'delegate'
1
+ require 'forwardable'
2
2
  require 'ostruct'
3
3
 
4
+ require 'reform/composition'
5
+ require 'reform/representer'
6
+
4
7
  module Reform
5
- class Form < SimpleDelegator
8
+ class Form
9
+ extend Forwardable
6
10
  # reasons for delegation:
7
11
  # presentation: this object is used in the presentation layer by #form_for.
8
12
  # problem: #form_for uses respond_to?(:email_before_type_cast) which goes to an internal hash in the actual record.
9
13
  # validation: this object also contains the validation rules itself, should be separated.
10
- # TODO: figure out #to_key issues.
11
14
 
12
- def initialize(mapper_class, composition)
13
- @mapper = mapper_class
14
- @model = composition
15
+ # Allows using property and friends in the Form itself. Forwarded to the internal representer_class.
16
+ module PropertyMethods
17
+ extend Forwardable
18
+
19
+ def property(name, options={}, &block)
20
+ definition = representer_class.property(name, options, &block)
21
+ setup_form_definition(definition) if block_given?
22
+ create_accessor(name)
23
+ end
24
+
25
+ def collection(name, options={}, &block)
26
+ options[:form_collection] = true
27
+
28
+ property(name, options, &block)
29
+ end
30
+
31
+ def properties(names, *args)
32
+ names.each { |name| property(name, *args) }
33
+ end
34
+
35
+ def setup_form_definition(definition)
36
+ definition.options[:form] = definition.options.delete(:extend)
37
+
38
+ definition.options[:parse_strategy] = :sync
39
+ definition.options[:instance] = true # just to make typed? work
40
+ end
41
+
42
+ def representer_class
43
+ @representer_class ||= Class.new(Reform::Representer)
44
+ end
45
+
46
+ private
47
+ def create_accessor(name)
48
+ delegate [name, "#{name}="] => :fields
49
+ end
50
+ end
51
+ extend PropertyMethods
52
+
15
53
 
16
- super(setup_fields(mapper_class, composition)) # delegate all methods to Fields instance.
54
+ def initialize(model)
55
+ @model = model # we need this for #save.
56
+ @fields = setup_fields(model) # delegate all methods to Fields instance.
17
57
  end
18
58
 
19
- def validate(params)
20
- # here it would be cool to have a validator object containing the validation rules representer-like and then pass it the formed model.
21
- update_with(params)
59
+ module ValidateMethods # TODO: introduce Base module.
60
+ def validate(params)
61
+ # here it would be cool to have a validator object containing the validation rules representer-like and then pass it the formed model.
62
+ from_hash(params)
63
+
64
+ res = valid? # this validates on <Fields> using AM::Validations, currently.
65
+ #inject(true) do |res, form| # FIXME: replace that!
66
+ mapper.new(@fields).nested_forms do |attr, form| #.collect { |attr, form| nested[attr.from] = form }
67
+ res = validate_for(form, res, attr.from)
68
+ end
69
+
70
+ res
71
+ end
72
+
73
+ private
74
+ def validate_for(form, res, prefix=nil)
75
+ return res if form.valid? # FIXME: we have to call validate here, otherwise this works only one level deep.
76
+
77
+ errors.merge!(form.errors, prefix)
78
+ false
79
+ end
22
80
 
23
- valid? # this validates on <Fields> using AM::Validations, currently.
24
81
  end
82
+ include ValidateMethods
25
83
 
26
84
  def save
27
85
  # DISCUSS: we should never hit @mapper here (which writes to the models) when a block is passed.
28
86
  return yield self, to_nested_hash if block_given?
29
87
 
30
- @mapper.new(model).from_hash(to_hash) # DISCUSS: move to Composition?
88
+ save_to_models
31
89
  end
32
90
 
33
91
  # Use representer to return current key-value form hash.
34
- def to_hash
92
+ def to_hash(*)
35
93
  mapper.new(self).to_hash
36
94
  end
37
95
 
38
96
  def to_nested_hash
39
- model.nested_hash_for(to_hash) # use composition to compute nested hash.
97
+ symbolize_keys(to_hash)
98
+ end
99
+
100
+ def from_hash(params, *args)
101
+ mapper.new(self).from_hash(params) # sets form properties found in params on self.
102
+ end
103
+
104
+ def errors
105
+ @errors ||= Errors.new(self)
40
106
  end
41
107
 
42
108
  private
43
- attr_accessor :mapper, :model
109
+ attr_accessor :model, :fields
110
+
111
+ def symbolize_keys(hash)
112
+ hash.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
113
+ end
114
+
115
+ def mapper
116
+ self.class.representer_class
117
+ end
44
118
 
45
- def setup_fields(mapper_class, composition)
46
- # decorate composition and transform to hash.
47
- representer = mapper_class.new(composition)
119
+ def setup_fields(model)
120
+ representer = Class.new(mapper).new(model)
121
+
122
+ setup_nested_forms(representer)
48
123
 
49
124
  create_fields(representer.fields, representer.to_hash)
50
125
  end
51
126
 
127
+ def setup_nested_forms(representer)
128
+ # TODO: we should simply give a FormBuilder instance to representer.to_hash that does this kind of mapping:
129
+ # after this, Fields contains scalars and Form instances and Forms with form instances.
130
+ representer.nested_forms do |attr, model|
131
+ form_class = attr.options[:form]
132
+
133
+ attr.options.merge!(
134
+ :getter => lambda do |*|
135
+ nested_model = send(attr.getter) # decorated.hit # TODO: use bin.get
136
+
137
+ if attr.options[:form_collection]
138
+ Forms.new(nested_model.collect { |mdl| form_class.new(mdl)})
139
+ else
140
+ form_class.new(nested_model)
141
+ end
142
+ end,
143
+ :instance => false, # that's how we make it non-typed?.
144
+ )
145
+ end
146
+
147
+ #representer.to_hash override: { write: lambda { |doc, value| } }
148
+
149
+ # DISCUSS: this would be cool in representable:
150
+ # to_hash(hit: lambda { |value| form_class.new(..) })
151
+
152
+ # steps:
153
+ # - bin.get
154
+ # - map that: Forms.new( orig ) <-- override only this in representable (how?)
155
+ # - mapped.to_hash
156
+ end
157
+
52
158
  def create_fields(field_names, fields)
53
159
  Fields.new(field_names, fields)
54
160
  end
55
161
 
56
- def update_with(params)
57
- mapper.new(self).from_hash(params) # sets form properties found in params on self.
162
+ def save_to_models
163
+ representer = mapper.new(model)
164
+
165
+ representer.nested_forms do |attr, model|
166
+ attr.options.merge!(
167
+ :decorator => attr.options[:form].representer_class
168
+ )
169
+
170
+ if attr.options[:form_collection]
171
+ attr.options.merge!(
172
+ :collection => true
173
+ )
174
+ end
175
+ end
176
+
177
+ representer.from_hash(to_hash)
58
178
  end
59
179
 
60
180
  # FIXME: make AM optional.
61
181
  require 'active_model'
62
182
  include ActiveModel::Validations
63
- end
64
-
65
- # Keeps values of the form fields. What's in here is to be displayed in the browser!
66
- # we need this intermediate object to display both "original values" and new input from the form after submitting.
67
- class Fields < OpenStruct
68
- def initialize(properties, values={})
69
- fields = properties.inject({}) { |hsh, attr| hsh.merge!(attr => nil) }
70
- super(fields.merge!(values)) # TODO: stringify value keys!
71
- end
72
- end
73
-
74
- # Keeps composition of models and knows how to transform a plain hash into a nested hash.
75
- class Composition
76
- class << self
77
- def map(options)
78
- @attr2obj = {} # {song: ["title", "track"], artist: ["name"]}
79
-
80
- options.each do |mdl, meths|
81
- create_accessors(mdl, meths)
82
- attr_reader mdl # FIXME: unless already defined!!
83
183
 
84
- meths.each { |m| @attr2obj[m.to_s] = mdl }
85
- end
184
+ # The Errors class is planned to replace AM::Errors. It provides proper nested error messages.
185
+ class Errors < ActiveModel::Errors
186
+ def messages
187
+ return super unless Reform.rails3_0?
188
+ self
86
189
  end
87
190
 
88
- # Specific to representable.
89
- def map_from(representer)
90
- options = {}
91
- representer.representable_attrs.each do |cfg|
92
- options[cfg.options[:on]] ||= []
93
- options[cfg.options[:on]] << cfg.name
94
- end
191
+ # def each
192
+ # messages.each_key do |attribute|
193
+ # self[attribute].each { |error| yield attribute, Array.wrap(error) }
194
+ # end
195
+ # end
95
196
 
96
- map options
97
- end
197
+ def merge!(errors, prefix=nil)
198
+ # TODO: merge into AM.
199
+ errors.messages.each do |field, msgs|
200
+ field = "#{prefix}.#{field}" if prefix
98
201
 
99
- def model_for_property(name)
100
- @attr2obj.fetch(name.to_s)
101
- end
202
+ msgs = [msgs] if Reform.rails3_0? # DISCUSS: fix in #each?
102
203
 
103
- private
104
- def create_accessors(model, methods)
105
- accessors = methods.collect { |m| [m, "#{m}="] }.flatten
106
- delegate *accessors << {:to => :"#{model}"}
204
+ msgs.each do |msg|
205
+ next if messages[field] and messages[field].include?(msg)
206
+ add(field, msg)
207
+ end # Forms now contains a plain errors hash. the errors for each item are still available in item.errors.
208
+ end
107
209
  end
108
210
  end
109
211
 
110
- # TODO: make class method?
111
- def nested_hash_for(attrs)
112
- {}.tap do |hsh|
113
- attrs.each do |name, val|
114
- obj = self.class.model_for_property(name)
115
- hsh[obj] ||= {}
116
- hsh[obj][name.to_sym] = val
212
+ require "representable/hash/collection"
213
+ require 'active_model'
214
+ class Forms < Array # DISCUSS: this should be a Form subclass.
215
+ include Form::ValidateMethods
216
+
217
+ def valid?
218
+ inject(true) do |res, form|
219
+ res = validate_for(form, res)
117
220
  end
118
221
  end
119
- end
120
222
 
121
- def initialize(models)
122
- models.each do |name, obj|
123
- instance_variable_set(:"@#{name}", obj)
223
+ def errors
224
+ @errors ||= Form::Errors.new(self)
124
225
  end
226
+
227
+ # this gives us each { to_hash }
228
+ include Representable::Hash::Collection
229
+ items :parse_strategy => :sync, :instance => true
125
230
  end
126
231
  end
127
232
 
128
- require 'representable/hash'
129
- class Representer < Representable::Decorator
130
- include Representable::Hash
131
233
 
132
- def self.properties(names, *args)
133
- names.each do |name|
134
- property(name, *args)
135
- end
234
+ # Keeps values of the form fields. What's in here is to be displayed in the browser!
235
+ # we need this intermediate object to display both "original values" and new input from the form after submitting.
236
+ class Fields < OpenStruct
237
+ def initialize(properties, values={})
238
+ fields = properties.inject({}) { |hsh, attr| hsh.merge!(attr => nil) }
239
+ super(fields.merge!(values)) # TODO: stringify value keys!
136
240
  end
241
+ end
137
242
 
138
- # Returns hash of all property names.
139
- def fields
140
- representable_attrs.collect { |cfg| cfg.name }
141
- end
243
+ def self.rails3_0?
244
+ ::ActiveModel::VERSION::MAJOR == 3 and ::ActiveModel::VERSION::MINOR == 0
142
245
  end
143
246
  end
data/lib/reform/rails.rb CHANGED
@@ -1,2 +1,9 @@
1
1
  require 'reform/form/active_model'
2
- require 'reform/form/active_record'
2
+ if defined?(ActiveRecord)
3
+ require 'reform/form/active_record'
4
+ end
5
+
6
+ Reform::Form.class_eval do # DISCUSS: i'd prefer having a separate Rails module to be mixed into the Form but this is way more convenient for 99% users.
7
+ include Reform::Form::ActiveModel
8
+ include Reform::Form::ActiveModel::FormBuilderMethods
9
+ end
@@ -0,0 +1,38 @@
1
+ require 'representable/hash'
2
+ require 'representable/decorator'
3
+
4
+ module Reform
5
+ class Representer < Representable::Decorator
6
+ include Representable::Hash
7
+
8
+ # Returns hash of all property names.
9
+ def fields
10
+ representable_attrs.map(&:name)
11
+ end
12
+
13
+ def nested_forms(&block)
14
+ clone_config!.
15
+ find_all { |attr| attr.options[:form] }.
16
+ collect { |attr| [attr, represented.send(attr.getter)] }. # DISCUSS: can't we do this with the Binding itself?
17
+ each(&block)
18
+ end
19
+
20
+ private
21
+ def clone_config!
22
+ # TODO: representable_attrs.clone! which does exactly what's done below.
23
+ attrs = Representable::Config.new
24
+ attrs.inherit(representable_attrs) # since in every use case we modify Config we clone.
25
+ @representable_attrs = attrs
26
+ end
27
+
28
+ def self.inline_representer(base_module, &block) # DISCUSS: separate module?
29
+ Class.new(Form) do
30
+ instance_exec &block
31
+
32
+ def self.name # FIXME: needed by ActiveModel::Validation - why?
33
+ "AnonInlineForm"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,3 @@
1
1
  module Reform
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/reform.rb CHANGED
@@ -1,4 +1,7 @@
1
- require 'reform/version'
2
1
  require 'reform/form'
3
- require 'reform/form/dsl'
2
+ require 'reform/form/composition'
4
3
  require 'reform/form/active_model'
4
+
5
+ if defined?(Rails) # DISCUSS: is everyone ok with this?
6
+ require 'reform/rails'
7
+ end
data/reform.gemspec CHANGED
@@ -18,12 +18,13 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.add_dependency "representable", ">= 1.5.3"
21
+ spec.add_dependency "representable", "~> 1.7.0"
22
22
  spec.add_dependency "activemodel"
23
23
  spec.add_development_dependency "bundler", "~> 1.3"
24
- spec.add_development_dependency "rake"
24
+ spec.add_development_dependency "rake", ">= 10.1.0"
25
25
  spec.add_development_dependency "minitest"
26
26
  spec.add_development_dependency "activerecord"
27
27
  spec.add_development_dependency "sqlite3"
28
28
  spec.add_development_dependency "virtus"
29
+ spec.add_development_dependency "rails"
29
30
  end