Skip to content

Commit e6634fb

Browse files
authored
Merge pull request #558 from matestack/20210610_517_nested_form_support_fixed
20210610 517 nested form support (fixed)
2 parents e31b12b + 42a4d19 commit e6634fb

File tree

25 files changed

+1803
-46
lines changed

25 files changed

+1803
-46
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Nested Forms
2+
3+
Matestack provides functionality for reactive nested forms.
4+
5+
This works in conjunction with rails' `accepts_nested_attributes_for`. From the rails documentation on [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html):
6+
7+
> Nested attributes allow you to save attributes on associated records through the parent. By default nested attribute updating is turned off and you can enable it using the accepts_nested_attributes_for class method. When you enable nested attributes an attribute writer is defined on the model.
8+
9+
There is a little bit of setup required to enable this. There's a need for `accepts_nested_attributes_for`, `index_errors` on a models' `has_many` associations and an ActiveRecord patch.
10+
11+
12+
Consider the following model setup, which is the same model found in the dummy app in the spec directory (active in this dummy app):
13+
14+
```ruby
15+
class DummyModel < ApplicationRecord
16+
validates :title, presence: true, uniqueness: true
17+
has_many :dummy_child_models, index_errors: true
18+
accepts_nested_attributes_for :dummy_child_models, allow_destroy: true
19+
end
20+
21+
class DummyChildModel < ApplicationRecord
22+
validates :title, presence: true, uniqueness: true
23+
end
24+
```
25+
26+
## Index Errors
27+
28+
Note the `has_many :dummy_child_models, index_errors: true` declaration in the `Dummy Model` declaration above.
29+
30+
Normally with rails, when rendering forms using Active Record models, errors are available on individual model instances. When using `accepts_nested_attributes_for`, error messages sent as JSON are not as useful because it is not possible to figure out which associated model object the error relates to.
31+
32+
From rails 5, we can add an index to errors on nested models. We can add the option `index_errors: true` to has_many association to enable this behaviour on individual association.
33+
34+
35+
## ActiveRecord Patch
36+
37+
Matestack nested forms support requires an ActiveRecord patch. This is because `index_errors` does not consider indexes of the correct existing sub records.
38+
39+
See rails [issue #24390](https://github.com/rails/rails/issues/24390)
40+
41+
Add this monkey patch to your rails app
42+
43+
```ruby
44+
module ActiveRecord
45+
module AutosaveAssociation
46+
def validate_collection_association(reflection)
47+
if association = association_instance_get(reflection.name)
48+
if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave])
49+
all_records = association.target.find_all
50+
records.each do |record|
51+
index = all_records.find_index(record)
52+
association_valid?(reflection, record, index)
53+
end
54+
end
55+
end
56+
end
57+
end
58+
end
59+
```
60+
61+
## Example
62+
63+
```ruby
64+
class ExamplePage < Matestack::Ui::Page
65+
66+
def prepare
67+
@dummy_model = DummyModel.new
68+
@dummy_model.dummy_child_models.build(title: "init-value")
69+
@dummy_model.dummy_child_models.build
70+
end
71+
72+
def response
73+
matestack_form form_config do
74+
form_input key: :title, type: :text, label: "dummy_model_title_input", id: "dummy_model_title_input"
75+
76+
@dummy_model.dummy_child_models.each do |dummy_child_model|
77+
dummy_child_model_form dummy_child_model
78+
end
79+
80+
form_fields_for_add_item key: :dummy_child_models_attributes, prototype: method(:dummy_child_model_form) do
81+
# type: :button is important! otherwise remove on first item is triggered on enter
82+
button "add", type: :button
83+
end
84+
85+
form_fields_for_remove_item do
86+
# id is just required in this spec, but type: :button is important! otherwise remove on first item is triggered on enter
87+
button "remove", ":id": "'remove'+nestedFormRuntimeId", type: :button
88+
end
89+
90+
button "Submit me!"
91+
92+
toggle show_on: "success", hide_after: 1000 do
93+
plain "success!"
94+
end
95+
toggle show_on: "failure", hide_after: 1000 do
96+
plain "failure!"
97+
end
98+
end
99+
end
100+
101+
def dummy_child_model_form dummy_child_model = DummyChildModel.new
102+
form_fields_for dummy_child_model, key: :dummy_child_models_attributes do
103+
form_input key: :title, type: :text, label: "dummy-child-model-title-input"
104+
form_fields_for_remove_item do
105+
# id is just required in this spec, but type: :button is important! otherwise remove on first item is triggered on enter
106+
button "remove", ":id": "'remove'+nestedFormRuntimeId", type: :button
107+
end
108+
end
109+
end
110+
end
111+
```
112+
113+
### Dynamically Adding Nested Items
114+
115+
As in the example above, you can dynamically add nested items. As the comment in the code suggests `type: :button` is important, otherwise remove on first item is triggered on enter.
116+
117+
```ruby
118+
form_fields_for_add_item key: :dummy_child_models_attributes, prototype: method(:dummy_child_model_form) do
119+
# type: :button is important! otherwise remove on first item is triggered on enter
120+
button "add", type: :button
121+
end
122+
```
123+
124+
### Dynamically Removing Nested Items
125+
126+
As in the example above, as well as dynamically adding items, you can dynamically remove nested items. Again, important: `type: :button` is important, otherwise remove on first item is triggered on enter.
127+
128+
```ruby
129+
form_fields_for_remove_item do
130+
# id is just required in this spec, but type: :button is important! otherwise remove on first item is triggered on enter
131+
button "remove", ":id": "'remove'+nestedFormRuntimeId", type: :button
132+
end
133+
```

lib/matestack/ui/core.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ module VueJs
4949
require "#{vue_js_base_path}/components/form/checkbox"
5050
require "#{vue_js_base_path}/components/form/radio"
5151
require "#{vue_js_base_path}/components/form/select"
52+
require "#{vue_js_base_path}/components/form/fields_for_remove_item"
53+
require "#{vue_js_base_path}/components/form/fields_for_add_item"
5254
require "#{vue_js_base_path}/components/collection/helper"
5355
require "#{vue_js_base_path}/components/collection/content"
5456
require "#{vue_js_base_path}/components/collection/filter"

lib/matestack/ui/vue_js/components.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ def matestack_form(text=nil, options=nil, &block)
3131
Matestack::Ui::VueJs::Components::Form::Form.(text, options, &block)
3232
end
3333

34+
def form_fields_for(text=nil, options=nil, &block)
35+
# in order to provide a more intuitiv API while calling the default
36+
# form, we transform the arguments a bit:
37+
options[:for] = text
38+
options[:fields_for] = options.delete(:key)
39+
text = nil
40+
Matestack::Ui::VueJs::Components::Form::Form.(text, options, &block)
41+
end
42+
43+
def form_fields_for_remove_item(text=nil, options=nil, &block)
44+
Matestack::Ui::VueJs::Components::Form::FieldsForRemoveItem.(text, options, &block)
45+
end
46+
47+
def form_fields_for_add_item(text=nil, options=nil, &block)
48+
Matestack::Ui::VueJs::Components::Form::FieldsForAddItem.(text, options, &block)
49+
end
50+
3451
def form_input(text=nil, options=nil, &block)
3552
Matestack::Ui::VueJs::Components::Form::Input.(text, options, &block)
3653
end

lib/matestack/ui/vue_js/components/form/base.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ def error_config
3434
end
3535

3636
def id
37-
ctx.id || key
37+
if ctx.id.present?
38+
"'#{ctx.id}'"
39+
else
40+
"'#{key}'+$parent.nestedFormRuntimeId"
41+
end
3842
end
3943

4044
def multiple
@@ -50,7 +54,7 @@ def placeholder
5054
def attributes
5155
(options || {}).merge({
5256
ref: "input.#{attribute_key}",
53-
id: id,
57+
":id": id,
5458
type: ctx.type,
5559
multiple: ctx.multiple,
5660
placeholder: ctx.placeholder,
@@ -95,7 +99,7 @@ def v_model_type(item=nil)
9599
item.is_a?(Integer) ? 'v-model.number' : 'v-model'
96100
end
97101
end
98-
102+
99103
# set value-type "Integer" for all numeric init values or options
100104
def value_type(item=nil)
101105
if item.nil?
@@ -164,4 +168,4 @@ def render_errors
164168
end
165169
end
166170
end
167-
end
171+
end

lib/matestack/ui/vue_js/components/form/checkbox.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ def vue_props
3737
def render_checkbox_options
3838
checkbox_options.to_a.each do |item|
3939
input checkbox_attributes(item)
40-
label item_label(item), for: item_id(item)
40+
label item_label(item), ":for": item_id(item)
4141
end
4242
end
4343

4444
def checkbox_attributes(item)
4545
{
46-
id: item_id(item),
46+
":id": item_id(item),
4747
type: :checkbox,
4848
name: item_label(item),
4949
value: item_value(item),
@@ -57,9 +57,9 @@ def checkbox_attributes(item)
5757
end
5858

5959
def render_true_false_checkbox
60-
input true_false_checkbox_attributes.merge(type: :hidden, id: nil, value: 0)
61-
input true_false_checkbox_attributes.merge(type: :checkbox, id: item_id(1))
62-
label input_label, for: item_id(1) if input_label
60+
input true_false_checkbox_attributes.merge(type: :hidden, ":id": nil, value: 0)
61+
input true_false_checkbox_attributes.merge(type: :checkbox, ":id": item_id(1))
62+
label input_label, ":for": item_id(1) if input_label
6363
end
6464

6565
def true_false_checkbox_attributes
@@ -88,18 +88,18 @@ def checkbox_options
8888
def item_value(item)
8989
item.is_a?(Array) ? item.last : item
9090
end
91-
91+
9292
def item_label(item)
9393
item.is_a?(Array) ? item.first : item
9494
end
9595

9696
def item_id(item)
97-
"#{id}_#{item_value(item).to_s.gsub(" ", '_')}"
97+
"#{id}+'_#{item_value(item).to_s.gsub(" ", '_')}'"
9898
end
9999

100100
end
101101
end
102102
end
103103
end
104104
end
105-
end
105+
end

lib/matestack/ui/vue_js/components/form/checkbox_mixin.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ const formCheckboxMixin = {
6464
}, 1);
6565
},
6666
inputChanged: function (key) {
67+
if (this.$parent.isNestedForm){
68+
this.$parent.data["_destroy"] = false;
69+
}
6770
this.$parent.resetErrors(key);
6871
this.$parent.$forceUpdate();
6972
this.$forceUpdate();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module Matestack
2+
module Ui
3+
module VueJs
4+
module Components
5+
module Form
6+
class FieldsForAddItem < Matestack::Ui::Component
7+
8+
required :key
9+
10+
required :prototype
11+
12+
attr_accessor :prototype_template_json
13+
14+
def create_children(&block)
15+
# first render prototype_template_json
16+
self.prototype_template_json = context.prototype.call().to_json
17+
# delete from children in order not to render the prototype
18+
self.children.shift
19+
super
20+
end
21+
22+
def response
23+
div id: "prototype-template-for-#{context.key}", "v-pre": true, data: { ":template": self.prototype_template_json }
24+
Matestack::Ui::Core::Base.new('v-runtime-template', ':template': "nestedFormRuntimeTemplates['#{context.key}']")
25+
a class: 'matestack-ui-core-form-fields-for-add-item', "@click.prevent": "addItem('#{context.key}')" do
26+
yield if block_given?
27+
end
28+
end
29+
30+
end
31+
end
32+
end
33+
end
34+
end
35+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module Matestack
2+
module Ui
3+
module VueJs
4+
module Components
5+
module Form
6+
class FieldsForRemoveItem < Matestack::Ui::Component
7+
8+
def response
9+
a class: 'matestack-ui-core-form-fields-for-remove-item', "@click.prevent": "removeItem()" do
10+
yield if block_given?
11+
end
12+
end
13+
14+
end
15+
end
16+
end
17+
end
18+
end
19+
end

0 commit comments

Comments
 (0)