reform 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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