reform 2.3.0.rc1 → 2.3.0.rc2

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +30 -0
  4. data/.rubocop_todo.yml +460 -0
  5. data/.travis.yml +26 -11
  6. data/CHANGES.md +25 -2
  7. data/Gemfile +6 -3
  8. data/ISSUE_TEMPLATE.md +1 -1
  9. data/README.md +2 -4
  10. data/Rakefile +18 -9
  11. data/lib/reform/contract.rb +7 -7
  12. data/lib/reform/contract/custom_error.rb +41 -0
  13. data/lib/reform/contract/validate.rb +9 -5
  14. data/lib/reform/errors.rb +27 -15
  15. data/lib/reform/form.rb +22 -11
  16. data/lib/reform/form/call.rb +1 -1
  17. data/lib/reform/form/composition.rb +2 -2
  18. data/lib/reform/form/dry.rb +10 -86
  19. data/lib/reform/form/dry/input_hash.rb +37 -0
  20. data/lib/reform/form/dry/new_api.rb +58 -0
  21. data/lib/reform/form/dry/old_api.rb +61 -0
  22. data/lib/reform/form/populator.rb +9 -11
  23. data/lib/reform/form/prepopulate.rb +3 -2
  24. data/lib/reform/form/validate.rb +19 -12
  25. data/lib/reform/result.rb +36 -9
  26. data/lib/reform/validation.rb +10 -8
  27. data/lib/reform/validation/groups.rb +2 -3
  28. data/lib/reform/version.rb +1 -1
  29. data/reform.gemspec +10 -9
  30. data/test/benchmarking.rb +10 -11
  31. data/test/call_new_api.rb +23 -0
  32. data/test/{call_test.rb → call_old_api.rb} +3 -3
  33. data/test/changed_test.rb +7 -7
  34. data/test/coercion_test.rb +50 -18
  35. data/test/composition_new_api.rb +186 -0
  36. data/test/{composition_test.rb → composition_old_api.rb} +23 -26
  37. data/test/contract/custom_error_test.rb +55 -0
  38. data/test/contract_new_api.rb +77 -0
  39. data/test/{contract_test.rb → contract_old_api.rb} +8 -8
  40. data/test/default_test.rb +1 -1
  41. data/test/deserialize_test.rb +8 -11
  42. data/test/errors_new_api.rb +225 -0
  43. data/test/errors_old_api.rb +230 -0
  44. data/test/feature_test.rb +7 -9
  45. data/test/fixtures/dry_error_messages.yml +5 -2
  46. data/test/fixtures/dry_new_api_error_messages.yml +104 -0
  47. data/test/form_new_api.rb +57 -0
  48. data/test/{form_test.rb → form_old_api.rb} +2 -2
  49. data/test/form_option_new_api.rb +24 -0
  50. data/test/{form_option_test.rb → form_option_old_api.rb} +1 -1
  51. data/test/from_test.rb +8 -12
  52. data/test/inherit_new_api.rb +105 -0
  53. data/test/{inherit_test.rb → inherit_old_api.rb} +10 -17
  54. data/test/module_new_api.rb +137 -0
  55. data/test/{module_test.rb → module_old_api.rb} +19 -15
  56. data/test/parse_option_test.rb +5 -5
  57. data/test/parse_pipeline_test.rb +2 -2
  58. data/test/populate_new_api.rb +304 -0
  59. data/test/{populate_test.rb → populate_old_api.rb} +28 -34
  60. data/test/populator_skip_test.rb +1 -2
  61. data/test/prepopulator_test.rb +5 -6
  62. data/test/read_only_test.rb +12 -1
  63. data/test/readable_test.rb +5 -5
  64. data/test/reform_new_api.rb +204 -0
  65. data/test/{reform_test.rb → reform_old_api.rb} +17 -23
  66. data/test/save_new_api.rb +101 -0
  67. data/test/{save_test.rb → save_old_api.rb} +10 -13
  68. data/test/setup_test.rb +6 -6
  69. data/test/{skip_if_test.rb → skip_if_new_api.rb} +20 -9
  70. data/test/skip_if_old_api.rb +92 -0
  71. data/test/skip_setter_and_getter_test.rb +2 -3
  72. data/test/test_helper.rb +13 -5
  73. data/test/validate_new_api.rb +408 -0
  74. data/test/{validate_test.rb → validate_old_api.rb} +43 -53
  75. data/test/validation/dry_validation_new_api.rb +826 -0
  76. data/test/validation/{dry_validation_test.rb → dry_validation_old_api.rb} +223 -116
  77. data/test/validation/result_test.rb +20 -22
  78. data/test/validation_library_provided_test.rb +3 -3
  79. data/test/virtual_test.rb +46 -6
  80. data/test/writeable_test.rb +7 -7
  81. metadata +101 -51
  82. data/test/errors_test.rb +0 -180
  83. data/test/readonly_test.rb +0 -14
@@ -7,7 +7,7 @@ module Reform::Validation
7
7
  end
8
8
 
9
9
  # DSL.
10
- def validation(name=nil, options={}, &block)
10
+ def validation(name = nil, options = {}, &block)
11
11
  options = deprecate_validation_positional_args(name, options)
12
12
  name = options[:name] # TODO: remove in favor of kw args in 3.0.
13
13
 
@@ -20,19 +20,17 @@ module Reform::Validation
20
20
  def deprecate_validation_positional_args(name, options)
21
21
  if name.is_a?(Symbol)
22
22
  warn "[Reform] Form::validation API is now: validation(name: :default, if:nil, schema:Schema). Please use keyword arguments instead of positional arguments."
23
- return { name: name }.merge(options)
23
+ return {name: name}.merge(options)
24
24
  end
25
25
 
26
- if name.nil?
27
- return { name: :default }.merge(options)
28
- end
26
+ return {name: :default}.merge(options) if name.nil?
29
27
 
30
- { name: :default }.merge(name)
28
+ {name: :default}.merge(name)
31
29
  end
32
30
 
33
31
  def validation_group_class
34
- raise NoValidationLibraryError, 'no validation library loaded. Please include a ' +
35
- 'validation library such as Reform::Form::Dry'
32
+ raise NoValidationLibraryError, "no validation library loaded. Please include a " +
33
+ "validation library such as Reform::Form::Dry"
36
34
  end
37
35
  end
38
36
 
@@ -40,6 +38,10 @@ module Reform::Validation
40
38
  includer.extend(ClassMethods)
41
39
  end
42
40
 
41
+ def valid?
42
+ validate({})
43
+ end
44
+
43
45
  NoValidationLibraryError = Class.new(RuntimeError)
44
46
  end
45
47
 
@@ -23,7 +23,7 @@ module Reform::Validation
23
23
  group
24
24
  end
25
25
 
26
- private
26
+ private
27
27
 
28
28
  def index_for(options)
29
29
  return find_index { |el| el.first == options[:after] } + 1 if options[:after]
@@ -36,7 +36,6 @@ module Reform::Validation
36
36
  cfg[1]
37
37
  end
38
38
 
39
-
40
39
  # Runs all validations groups according to their rules and returns all Result objects.
41
40
  class Validate
42
41
  def self.call(groups, form)
@@ -51,7 +50,7 @@ module Reform::Validation
51
50
 
52
51
  def self.evaluate?(depends_on, results, form)
53
52
  return true if depends_on.nil?
54
- return results[depends_on].success? if depends_on.is_a?(Symbol)
53
+ return !results[depends_on].nil? && results[depends_on].success? if depends_on.is_a?(Symbol)
55
54
  form.instance_exec(results, &depends_on)
56
55
  end
57
56
  end
@@ -1,3 +1,3 @@
1
1
  module Reform
2
- VERSION = "2.3.0.rc1"
2
+ VERSION = "2.3.0.rc2".freeze
3
3
  end
@@ -1,30 +1,31 @@
1
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path("lib", __dir__)
2
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require 'reform/version'
3
+ require "reform/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "reform"
7
7
  spec.version = Reform::VERSION
8
8
  spec.authors = ["Nick Sutterer", "Fran Worley"]
9
9
  spec.email = ["apotonick@gmail.com", "frances@safetytoolbox.co.uk"]
10
- spec.description = %q{Form object decoupled from models.}
11
- spec.summary = %q{Form object decoupled from models with validation, population and presentation.}
10
+ spec.description = "Form object decoupled from models."
11
+ spec.summary = "Form object decoupled from models with validation, population and presentation."
12
12
  spec.homepage = "https://github.com/trailblazer/reform"
13
13
  spec.license = "MIT"
14
14
 
15
15
  spec.files = `git ls-files`.split($/)
16
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
+ spec.executables = spec.files.grep(%r(^bin/)) { |f| File.basename(f) }
17
17
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
18
  spec.require_paths = ["lib"]
19
19
 
20
20
  spec.add_dependency "disposable", ">= 0.4.2", "< 0.5.0"
21
- spec.add_dependency "uber", "< 0.2.0"
22
21
  spec.add_dependency "representable", ">= 2.4.0", "< 3.1.0"
22
+ spec.add_dependency "uber", "< 0.2.0"
23
23
 
24
24
  spec.add_development_dependency "bundler"
25
- spec.add_development_dependency "rake"
26
25
  spec.add_development_dependency "minitest"
27
- spec.add_development_dependency "dry-types"
26
+ spec.add_development_dependency "minitest-line"
27
+ spec.add_development_dependency "byebug"
28
28
  spec.add_development_dependency "multi_json"
29
- spec.add_development_dependency "dry-validation", ">= 0.10.1"
29
+ spec.add_development_dependency "rake"
30
+ spec.add_development_dependency "rubocop"
30
31
  end
@@ -1,29 +1,29 @@
1
- require 'reform'
2
- require 'benchmark/ips'
1
+ require "reform"
2
+ require "benchmark/ips"
3
3
  require "reform/form/dry"
4
4
 
5
5
  class BandForm < Reform::Form
6
6
  feature Reform::Form::Dry
7
- property :name#, validates: {presence: true}
7
+ property :name #, validates: {presence: true}
8
8
  collection :songs do
9
- property :title#, validates: {presence: true}
9
+ property :title #, validates: {presence: true}
10
10
  end
11
11
  end
12
12
 
13
13
  class OptimizedBandForm < Reform::Form
14
14
  feature Reform::Form::Dry
15
- property :name#, validates: {presence: true}
15
+ property :name #, validates: {presence: true}
16
16
  collection :songs do
17
- property :title#, validates: {presence: true}
18
-
17
+ property :title #, validates: {presence: true}
18
+
19
19
  def deserializer(*args)
20
- # DISCUSS: should we simply delegate to class and sort out memoizing there?
20
+ # DISCUSS: should we simply delegate to class and sort out memoizing there?
21
21
  self.class.deserializer_class || self.class.deserializer_class = deserializer!(*args)
22
- end
22
+ end
23
23
  end
24
24
 
25
25
  def deserializer(*args)
26
- # DISCUSS: should we simply delegate to class and sort out memoizing there?
26
+ # DISCUSS: should we simply delegate to class and sort out memoizing there?
27
27
  self.class.deserializer_class || self.class.deserializer_class = deserializer!(*args)
28
28
  end
29
29
  end
@@ -43,7 +43,6 @@ end
43
43
 
44
44
  exit
45
45
 
46
-
47
46
  songs = 50.times.collect { OpenStruct.new(title: "Be Stag") }
48
47
  band = OpenStruct.new(name: "Teenage Bottlerock", songs: songs)
49
48
 
@@ -0,0 +1,23 @@
1
+ require "test_helper"
2
+
3
+ class CallTest < Minitest::Spec
4
+ Song = Struct.new(:title)
5
+
6
+ class SongForm < TestForm
7
+ property :title
8
+
9
+ validation do
10
+ params { required(:title).filled }
11
+ end
12
+ end
13
+
14
+ let(:form) { SongForm.new(Song.new) }
15
+
16
+ it { form.(title: "True North").success?.must_equal true }
17
+ it { form.(title: "True North").failure?.must_equal false }
18
+ it { form.(title: "").success?.must_equal false }
19
+ it { form.(title: "").failure?.must_equal true }
20
+
21
+ it { form.(title: "True North").errors.messages.must_equal({}) }
22
+ it { form.(title: "").errors.messages.must_equal(title: ["must be filled"]) }
23
+ end
@@ -7,11 +7,11 @@ class CallTest < Minitest::Spec
7
7
  property :title
8
8
 
9
9
  validation do
10
- key(:title).required
10
+ required(:title).filled
11
11
  end
12
12
  end
13
13
 
14
- let (:form) { SongForm.new(Song.new) }
14
+ let(:form) { SongForm.new(Song.new) }
15
15
 
16
16
  it { form.(title: "True North").success?.must_equal true }
17
17
  it { form.(title: "True North").failure?.must_equal false }
@@ -19,5 +19,5 @@ class CallTest < Minitest::Spec
19
19
  it { form.(title: "").failure?.must_equal true }
20
20
 
21
21
  it { form.(title: "True North").errors.messages.must_equal({}) }
22
- it { form.(title: "").errors.messages.must_equal({:title=>["must be filled"]}) }
22
+ it { form.(title: "").errors.messages.must_equal(title: ["must be filled"]) }
23
23
  end
@@ -1,5 +1,5 @@
1
- require 'test_helper'
2
- require 'reform/form/coercion'
1
+ require "test_helper"
2
+ require "reform/form/coercion"
3
3
 
4
4
  class ChangedTest < MiniTest::Spec
5
5
  Song = Struct.new(:title, :album, :composer)
@@ -18,11 +18,11 @@ class ChangedTest < MiniTest::Spec
18
18
  end
19
19
  end
20
20
 
21
- let (:song_with_composer) { Song.new("Resist Stance", nil, composer) }
22
- let (:composer) { Artist.new("Greg Graffin") }
23
- let (:album) { Album.new("The Dissent Of Man", [song_with_composer]) }
21
+ let(:song_with_composer) { Song.new("Resist Stance", nil, composer) }
22
+ let(:composer) { Artist.new("Greg Graffin") }
23
+ let(:album) { Album.new("The Dissent Of Man", [song_with_composer]) }
24
24
 
25
- let (:form) { AlbumForm.new(album) }
25
+ let(:form) { AlbumForm.new(album) }
26
26
 
27
27
  # nothing changed after setup.
28
28
  it do
@@ -38,4 +38,4 @@ class ChangedTest < MiniTest::Spec
38
38
  form.songs[0].changed?(:title).must_equal false
39
39
  form.songs[0].composer.changed?(:name).must_equal true
40
40
  end
41
- end
41
+ end
@@ -1,21 +1,23 @@
1
1
  require "test_helper"
2
2
  require "reform/form/coercion"
3
+ require "disposable/twin/property/hash"
3
4
 
4
5
  class CoercionTest < BaseTest
5
6
  class Irreversible
6
7
  def self.call(value)
7
- value*2
8
+ value * 2
8
9
  end
9
10
  end
10
11
 
11
12
  class Form < TestForm
12
13
  feature Coercion
14
+ include Disposable::Twin::Property::Hash
13
15
 
14
- property :released_at, type: Types::Form::DateTime
16
+ property :released_at, type: DRY_TYPES_CONSTANT::DateTime
15
17
 
16
18
  property :hit do
17
- property :length, type: Types::Form::Int
18
- property :good, type: Types::Form::Bool
19
+ property :length, type: DRY_TYPES_INT_CONSTANT
20
+ property :good, type: DRY_TYPES_CONSTANT::Bool
19
21
  end
20
22
 
21
23
  property :band do
@@ -23,34 +25,51 @@ class CoercionTest < BaseTest
23
25
  property :value, type: Irreversible
24
26
  end
25
27
  end
28
+
29
+ property :metadata, field: :hash do
30
+ property :publication_settings do
31
+ property :featured, type: DRY_TYPES_CONSTANT::Bool
32
+ end
33
+ end
26
34
  end
27
35
 
28
36
  subject do
29
37
  Form.new(album)
30
38
  end
31
39
 
32
- let (:album) {
40
+ let(:album) do
33
41
  OpenStruct.new(
34
- :released_at => "31/03/1981",
35
- :hit => OpenStruct.new(:length => "312"),
36
- :band => Band.new(OpenStruct.new(:value => "9999.99"))
42
+ released_at: "31/03/1981",
43
+ hit: OpenStruct.new(length: "312"),
44
+ band: Band.new(OpenStruct.new(value: "9999.99")),
45
+ metadata: {}
37
46
  )
38
- }
47
+ end
39
48
 
40
49
  # it { subject.released_at.must_be_kind_of DateTime }
41
50
  it { subject.released_at.must_equal "31/03/1981" } # NO coercion in setup.
42
51
  it { subject.hit.length.must_equal "312" }
43
52
  it { subject.band.label.value.must_equal "9999.99" }
44
53
 
45
-
46
- let (:params) {
54
+ let(:params) do
47
55
  {
48
- :released_at => "30/03/1981",
49
- :hit => {:length => "312"},
50
- :band => {:label => {:value => "9999.99"}}
56
+ released_at: "30/03/1981",
57
+ hit: {
58
+ length: "312",
59
+ good: "0",
60
+ },
61
+ band: {
62
+ label: {
63
+ value: "9999.99"
64
+ }
65
+ },
66
+ metadata: {
67
+ publication_settings: {
68
+ featured: "0"
69
+ }
70
+ }
51
71
  }
52
- }
53
-
72
+ end
54
73
 
55
74
  # validate
56
75
  describe "#validate" do
@@ -58,9 +77,22 @@ class CoercionTest < BaseTest
58
77
 
59
78
  it { subject.released_at.must_equal DateTime.parse("30/03/1981") }
60
79
  it { subject.hit.length.must_equal 312 }
61
- it { assert_nil subject.hit.good }
80
+ it { subject.hit.good.must_equal false }
62
81
  it { subject.band.label.value.must_equal "9999.999999.99" } # coercion happened once.
82
+ it { subject.metadata.publication_settings.featured.must_equal false }
63
83
  end
64
84
 
65
- # save
85
+ # sync
86
+ describe "#sync" do
87
+ before do
88
+ subject.validate(params).must_equal true
89
+ subject.sync
90
+ end
91
+
92
+ it { album.released_at.must_equal DateTime.parse("30/03/1981") }
93
+ it { album.hit.length.must_equal 312 }
94
+ it { album.hit.good.must_equal false }
95
+ it { assert_nil album.metadata[:publication_settings] }
96
+ it { album.metadata["publication_settings"]["featured"].must_equal false }
97
+ end
66
98
  end
@@ -0,0 +1,186 @@
1
+ require "test_helper"
2
+
3
+ class FormCompositionInheritanceTest < MiniTest::Spec
4
+ module SizePrice
5
+ include Reform::Form::Module
6
+
7
+ property :price
8
+ property :size
9
+
10
+ module InstanceMethods
11
+ def price(for_size: size)
12
+ case for_size.to_sym
13
+ when :s then super() * 1
14
+ when :m then super() * 2
15
+ when :l then super() * 3
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ class OutfitForm < TestForm
22
+ include Reform::Form::Composition
23
+ include SizePrice
24
+
25
+ property :price, inherit: true, on: :tshirt
26
+ property :size, inherit: true, on: :measurement
27
+ end
28
+
29
+ let(:measurement) { Measurement.new(:l) }
30
+ let(:tshirt) { Tshirt.new(2, :m) }
31
+ let(:form) { OutfitForm.new(tshirt: tshirt, measurement: measurement) }
32
+
33
+ Tshirt = Struct.new(:price, :size)
34
+ Measurement = Struct.new(:size)
35
+
36
+ it { form.price.must_equal 6 }
37
+ it { form.price(for_size: :s).must_equal 2 }
38
+ end
39
+
40
+ class FormCompositionTest < MiniTest::Spec
41
+ Song = Struct.new(:id, :title, :band)
42
+ Requester = Struct.new(:id, :name, :requester)
43
+ Band = Struct.new(:title)
44
+
45
+ class RequestForm < TestForm
46
+ include Composition
47
+
48
+ property :name, on: :requester
49
+ property :requester_id, on: :requester, from: :id
50
+ properties :title, :id, on: :song
51
+ # property :channel # FIXME: what about the "main model"?
52
+ property :channel, virtual: true, on: :song
53
+ property :requester, on: :requester
54
+ property :captcha, on: :song, virtual: true
55
+
56
+ validation do
57
+ params do
58
+ required(:name).filled
59
+ required(:title).filled
60
+ end
61
+ end
62
+
63
+ property :band, on: :song do
64
+ property :title
65
+ end
66
+ end
67
+
68
+ let(:form) { RequestForm.new(song: song, requester: requester) }
69
+ let(:song) { Song.new(1, "Rio", band) }
70
+ let(:requester) { Requester.new(2, "Duran Duran", "MCP") }
71
+ let(:band) { Band.new("Duran^2") }
72
+
73
+ # delegation form -> composition works
74
+ it { form.id.must_equal 1 }
75
+ it { form.title.must_equal "Rio" }
76
+ it { form.name.must_equal "Duran Duran" }
77
+ it { form.requester_id.must_equal 2 }
78
+ it { assert_nil form.channel }
79
+ it { form.requester.must_equal "MCP" } # same name as composed model.
80
+ it { assert_nil form.captcha }
81
+
82
+ # #model just returns <Composition>.
83
+ it { form.mapper.must_be_kind_of Disposable::Composition }
84
+
85
+ # #model[] -> composed models
86
+ it { form.model[:requester].must_equal requester }
87
+ it { form.model[:song].must_equal song }
88
+
89
+ it "creates Composition for you" do
90
+ form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb").must_equal true
91
+ form.validate("title" => "", "name" => "Frenzal Rhomb").must_equal false
92
+ end
93
+
94
+ describe "#save" do
95
+ # #save with {}
96
+ it do
97
+ hash = {}
98
+
99
+ form.save do |map|
100
+ hash[:name] = form.name
101
+ hash[:title] = form.title
102
+ end
103
+
104
+ hash.must_equal({name: "Duran Duran", title: "Rio"})
105
+ end
106
+
107
+ it "provides nested symbolized hash as second block argument" do
108
+ form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb", "channel" => "JJJ", "captcha" => "wonderful")
109
+
110
+ hash = nil
111
+
112
+ form.save do |map|
113
+ hash = map
114
+ end
115
+
116
+ hash.must_equal({
117
+ song: {"title" => "Greyhound", "id" => 1, "channel" => "JJJ", "captcha" => "wonderful", "band" => {"title" => "Duran^2"}},
118
+ requester: {"name" => "Frenzal Rhomb", "id" => 2, "requester" => "MCP"}
119
+ }
120
+ )
121
+ end
122
+
123
+ it "xxx pushes data to models and calls #save when no block passed" do
124
+ song.extend(Saveable)
125
+ requester.extend(Saveable)
126
+ band.extend(Saveable)
127
+
128
+ form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb", "captcha" => "1337")
129
+ form.captcha.must_equal "1337" # TODO: move to separate test.
130
+
131
+ form.save
132
+
133
+ requester.name.must_equal "Frenzal Rhomb"
134
+ requester.saved?.must_equal true
135
+ song.title.must_equal "Greyhound"
136
+ song.saved?.must_equal true
137
+ song.band.title.must_equal "Duran^2"
138
+ song.band.saved?.must_equal true
139
+ end
140
+
141
+ it "returns true when models all save successfully" do
142
+ song.extend(Saveable)
143
+ requester.extend(Saveable)
144
+ band.extend(Saveable)
145
+
146
+ form.save.must_equal true
147
+ end
148
+
149
+ it "returns false when one or more models don't save successfully" do
150
+ module Unsaveable
151
+ def save
152
+ false
153
+ end
154
+ end
155
+
156
+ song.extend(Unsaveable)
157
+ requester.extend(Saveable)
158
+ band.extend(Saveable)
159
+
160
+ form.save.must_equal false
161
+ end
162
+ end
163
+ end
164
+
165
+ class FormCompositionCollectionTest < MiniTest::Spec
166
+ Book = Struct.new(:id, :name)
167
+ Library = Struct.new(:id) do
168
+ def books
169
+ [Book.new(1, "My book")]
170
+ end
171
+ end
172
+
173
+ class LibraryForm < TestForm
174
+ include Reform::Form::Composition
175
+
176
+ collection :books, on: :library do
177
+ property :id
178
+ property :name
179
+ end
180
+ end
181
+
182
+ let(:form) { LibraryForm.new(library: library) }
183
+ let(:library) { Library.new(2) }
184
+
185
+ it { form.save { |hash| hash.must_equal({library: {"books" => [{"id" => 1, "name" => "My book"}]}}) } }
186
+ end