active_dry_form 0.1.0 → 1.1.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +18 -1
- data/.tool-versions +1 -0
- data/Gemfile +6 -3
- data/Gemfile.lock +212 -0
- data/README.md +272 -11
- data/Rakefile +3 -3
- data/config/locales/dry_validation.ru.yml +62 -0
- data/lib/active_dry_form/base_contract.rb +10 -0
- data/lib/active_dry_form/base_form.rb +285 -0
- data/lib/active_dry_form/builder.rb +83 -48
- data/lib/active_dry_form/configuration.rb +46 -0
- data/lib/active_dry_form/form.rb +16 -181
- data/lib/active_dry_form/form_helper.rb +11 -4
- data/lib/active_dry_form/input.rb +19 -24
- data/lib/active_dry_form/version.rb +3 -1
- data/lib/active_dry_form.rb +18 -7
- metadata +57 -9
- data/lib/active_dry_form/schema_compiler_patch.rb +0 -31
@@ -0,0 +1,285 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveDryForm
|
4
|
+
class BaseForm
|
5
|
+
|
6
|
+
attr_accessor :data, :parent_form, :errors, :base_errors
|
7
|
+
attr_reader :record, :validator, :attributes
|
8
|
+
|
9
|
+
def initialize(record: nil, params: nil)
|
10
|
+
@attributes = {}
|
11
|
+
|
12
|
+
self.params = params if params
|
13
|
+
self.record = record if record
|
14
|
+
|
15
|
+
@errors = {}
|
16
|
+
@base_errors = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def record=(value)
|
20
|
+
@record =
|
21
|
+
if value.is_a?(Hash)
|
22
|
+
hr = HashRecord.new
|
23
|
+
hr.replace(value)
|
24
|
+
hr.define_methods
|
25
|
+
hr
|
26
|
+
else
|
27
|
+
value
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def params=(params)
|
32
|
+
param_key = self.class::NAMESPACE.param_key
|
33
|
+
form_params = params[param_key] || params[param_key.to_sym] || params
|
34
|
+
|
35
|
+
if form_params.is_a?(::ActionController::Parameters)
|
36
|
+
unless ActiveDryForm.config.allow_action_controller_params
|
37
|
+
message = 'in `params` use `request.parameters` instead of `params` or set `allow_action_controller_params` to `true` in config'
|
38
|
+
raise ParamsNotAllowedError, message
|
39
|
+
end
|
40
|
+
|
41
|
+
form_params = form_params.to_unsafe_h
|
42
|
+
end
|
43
|
+
|
44
|
+
self.attributes = form_params
|
45
|
+
end
|
46
|
+
|
47
|
+
def attributes=(attrs)
|
48
|
+
attrs.each do |attr, v|
|
49
|
+
next if !ActiveDryForm.config.strict_param_keys && !respond_to?(:"#{attr}=")
|
50
|
+
|
51
|
+
public_send(:"#{attr}=", v)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def errors_full_messages
|
56
|
+
return if errors.blank?
|
57
|
+
|
58
|
+
errors.flat_map do |field, errors|
|
59
|
+
case errors
|
60
|
+
when Array
|
61
|
+
"#{t(model_name.i18n_key, field)}: #{errors.join(',')}"
|
62
|
+
when Hash
|
63
|
+
errors.map do |k, v|
|
64
|
+
case k
|
65
|
+
when Integer
|
66
|
+
nested_key, nested_errors = v.to_a.first
|
67
|
+
"#{t(field, nested_key)} (#{k + 1}): #{nested_errors.join(',')}"
|
68
|
+
when Symbol
|
69
|
+
"#{t(field, k)}: #{v.join(',')}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def t(*keys)
|
77
|
+
str_keys = keys.join('.')
|
78
|
+
I18n.t("helpers.label.#{str_keys}", default: :"activerecord.attributes.#{str_keys}")
|
79
|
+
end
|
80
|
+
|
81
|
+
def persisted?
|
82
|
+
record&.persisted?
|
83
|
+
end
|
84
|
+
|
85
|
+
def model_name
|
86
|
+
self.class::NAMESPACE
|
87
|
+
end
|
88
|
+
|
89
|
+
def info(sub_key)
|
90
|
+
{
|
91
|
+
type: self.class::FIELDS_INFO.dig(:properties, sub_key, :format) || self.class::FIELDS_INFO.dig(:properties, sub_key, :type),
|
92
|
+
required: self.class::FIELDS_INFO[:required].include?(sub_key.to_s),
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
# ActionView::Helpers::Tags::Translator#human_attribute_name
|
97
|
+
def to_model
|
98
|
+
self
|
99
|
+
end
|
100
|
+
|
101
|
+
def to_key
|
102
|
+
key = id
|
103
|
+
[key] if key
|
104
|
+
end
|
105
|
+
|
106
|
+
# hidden field for nested association
|
107
|
+
def id
|
108
|
+
record&.id
|
109
|
+
end
|
110
|
+
|
111
|
+
# используется при генерации URL, когда record.persisted?
|
112
|
+
def to_param
|
113
|
+
id.to_s
|
114
|
+
end
|
115
|
+
|
116
|
+
def validate
|
117
|
+
@validator = self.class::CURRENT_CONTRACT.call(attributes, { form: self, record: record })
|
118
|
+
@data = @validator.values.data
|
119
|
+
@errors = @validator.errors.to_h
|
120
|
+
@base_errors = @validator.errors.filter(:base?).map(&:to_s)
|
121
|
+
|
122
|
+
@is_valid = @base_errors.empty? && @errors.empty?
|
123
|
+
|
124
|
+
_deep_validate_nested
|
125
|
+
end
|
126
|
+
|
127
|
+
def valid?
|
128
|
+
@is_valid
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.human_attribute_name(field)
|
132
|
+
I18n.t(field, scope: :"activerecord.attributes.#{self::NAMESPACE.i18n_key}")
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.wrap(object)
|
136
|
+
return object if object.is_a?(BaseForm)
|
137
|
+
|
138
|
+
form = new
|
139
|
+
form.attributes = object if object
|
140
|
+
form
|
141
|
+
end
|
142
|
+
|
143
|
+
def [](key)
|
144
|
+
public_send(key)
|
145
|
+
end
|
146
|
+
|
147
|
+
def []=(key, value)
|
148
|
+
public_send(:"#{key}=", value)
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.define_methods
|
152
|
+
const_set :NESTED_FORM_KEYS, []
|
153
|
+
|
154
|
+
self::FIELDS_INFO[:properties].each do |key, value|
|
155
|
+
define_method :"#{key}=" do |v|
|
156
|
+
attributes[key] = _deep_transform_values_in_params!(v)
|
157
|
+
end
|
158
|
+
|
159
|
+
sub_klass =
|
160
|
+
if value[:properties] || value.dig(:items, :properties)
|
161
|
+
Class.new(BaseForm).tap do |klass|
|
162
|
+
klass.const_set :NAMESPACE, ActiveModel::Name.new(nil, nil, key.to_s)
|
163
|
+
klass.const_set :FIELDS_INFO, value[:items] || value
|
164
|
+
klass.define_methods
|
165
|
+
end
|
166
|
+
elsif const_defined?(:CURRENT_CONTRACT)
|
167
|
+
dry_type = self::CURRENT_CONTRACT.schema.schema_dsl.types[key]
|
168
|
+
dry_type = dry_type.member if dry_type.respond_to?(:member)
|
169
|
+
dry_type.primitive if dry_type.respond_to?(:primitive)
|
170
|
+
end
|
171
|
+
|
172
|
+
if sub_klass && sub_klass < BaseForm
|
173
|
+
self::NESTED_FORM_KEYS << {
|
174
|
+
type: sub_klass.const_defined?(:CURRENT_CONTRACT) ? :instance : :hash,
|
175
|
+
namespace: key,
|
176
|
+
is_array: value[:type] == 'array',
|
177
|
+
}
|
178
|
+
nested_key = key
|
179
|
+
end
|
180
|
+
|
181
|
+
if nested_key && value[:type] == 'array'
|
182
|
+
define_method nested_key do
|
183
|
+
nested_records = record.try(nested_key) || []
|
184
|
+
if attributes.key?(nested_key)
|
185
|
+
attributes[nested_key].each_with_index do |nested_params, idx|
|
186
|
+
attributes[nested_key][idx] = sub_klass.wrap(nested_params)
|
187
|
+
attributes[nested_key][idx].record = nested_records[idx]
|
188
|
+
attributes[nested_key][idx].parent_form = self
|
189
|
+
attributes[nested_key][idx]
|
190
|
+
end
|
191
|
+
else
|
192
|
+
attributes[nested_key] =
|
193
|
+
nested_records.map do |nested_record|
|
194
|
+
nested_form = sub_klass.new
|
195
|
+
nested_form.record = nested_record
|
196
|
+
nested_form.parent_form = self
|
197
|
+
nested_form
|
198
|
+
end
|
199
|
+
end
|
200
|
+
attributes[nested_key]
|
201
|
+
end
|
202
|
+
elsif nested_key
|
203
|
+
define_method nested_key do
|
204
|
+
attributes[nested_key] = sub_klass.wrap(attributes[nested_key])
|
205
|
+
attributes[nested_key].record = record.try(nested_key)
|
206
|
+
attributes[nested_key].parent_form = self
|
207
|
+
attributes[nested_key]
|
208
|
+
end
|
209
|
+
else
|
210
|
+
define_method key do
|
211
|
+
(data || attributes).fetch(key) { record.try(key) }
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
private def _deep_transform_values_in_params!(object)
|
218
|
+
return object if object.is_a?(BaseForm)
|
219
|
+
|
220
|
+
case object
|
221
|
+
when String
|
222
|
+
object.strip.presence
|
223
|
+
when Hash
|
224
|
+
object.transform_values! { |value| _deep_transform_values_in_params!(value) }
|
225
|
+
when Array
|
226
|
+
object.map! { |e| _deep_transform_values_in_params!(e) }
|
227
|
+
object.compact!
|
228
|
+
object
|
229
|
+
else
|
230
|
+
object
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
private def _deep_validate_nested
|
235
|
+
self.class::NESTED_FORM_KEYS.each do |nested_info|
|
236
|
+
namespace, type, is_array = nested_info.values_at(:namespace, :type, :is_array)
|
237
|
+
next unless attributes.key?(namespace)
|
238
|
+
|
239
|
+
nested_data = public_send(namespace)
|
240
|
+
|
241
|
+
if type == :hash && is_array
|
242
|
+
nested_data.each_with_index do |nested_form, idx|
|
243
|
+
nested_form.errors = @errors.dig(namespace, idx) || {}
|
244
|
+
nested_form.data = @data.dig(namespace, idx)
|
245
|
+
end
|
246
|
+
elsif type == :hash
|
247
|
+
nested_data.errors = @errors[namespace] || {}
|
248
|
+
nested_data.data = @data[namespace]
|
249
|
+
elsif type == :instance && is_array
|
250
|
+
@data[namespace] = []
|
251
|
+
nested_data.each_with_index do |nested_form, idx|
|
252
|
+
nested_form.validate
|
253
|
+
@data[namespace][idx] = nested_form.data
|
254
|
+
@base_errors += nested_form.base_errors
|
255
|
+
@is_valid &= nested_form.valid?
|
256
|
+
end
|
257
|
+
else
|
258
|
+
nested_data.validate
|
259
|
+
@data[namespace] = nested_data.data
|
260
|
+
@base_errors += nested_data.base_errors
|
261
|
+
@is_valid &= nested_data.valid?
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
class HashRecord < Hash
|
267
|
+
|
268
|
+
def persisted?
|
269
|
+
false
|
270
|
+
end
|
271
|
+
|
272
|
+
def id
|
273
|
+
self[:id] || self['id']
|
274
|
+
end
|
275
|
+
|
276
|
+
def define_methods
|
277
|
+
keys.each do |key|
|
278
|
+
define_singleton_method(key) { fetch(key) }
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
end
|
283
|
+
|
284
|
+
end
|
285
|
+
end
|
@@ -3,74 +3,109 @@
|
|
3
3
|
module ActiveDryForm
|
4
4
|
class Builder < ActionView::Helpers::FormBuilder
|
5
5
|
|
6
|
-
|
7
|
-
dry_tag = ActiveDryForm::Input.new(self, __method__, method, options)
|
8
|
-
|
9
|
-
input_tag =
|
10
|
-
case dry_tag.input_type
|
11
|
-
when 'date' then text_field(method, dry_tag.input_opts.merge('data-controller': 'flatpickr'))
|
12
|
-
when 'date_time' then text_field(method, dry_tag.input_opts.merge('data-controller': 'flatpickr', 'data-flatpickr-enable-time': 'true'))
|
13
|
-
when 'integer' then number_field(method, dry_tag.input_opts)
|
14
|
-
when 'bool' then check_box(method, dry_tag.input_opts)
|
15
|
-
else
|
16
|
-
case method.to_s
|
17
|
-
when /password/ then password_field(method, dry_tag.input_opts)
|
18
|
-
when /email/ then email_field(method, dry_tag.input_opts)
|
19
|
-
when /phone/ then telephone_field(method, dry_tag.input_opts)
|
20
|
-
when /url/ then url_field(method, dry_tag.input_opts)
|
21
|
-
else text_field(method, dry_tag.input_opts)
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
dry_tag.wrap_tag input_tag
|
26
|
-
end
|
6
|
+
include Dry::Core::Constants
|
27
7
|
|
28
|
-
def
|
29
|
-
|
30
|
-
|
8
|
+
def input(field, options = {})
|
9
|
+
case input_type(field)
|
10
|
+
when 'date' then input_date(field, options)
|
11
|
+
when 'time' then input_datetime(field, options)
|
12
|
+
when 'date-time' then raise DateTimeNotAllowedError, 'use :time instead of :date_time (does not apply timezone) in params block'
|
13
|
+
when 'integer' then input_integer(field, options)
|
14
|
+
when 'number' then input_number(field, options)
|
15
|
+
when 'boolean' then input_check_box(field, options)
|
16
|
+
else
|
17
|
+
case field.to_s
|
18
|
+
when /password/ then input_password(field, options)
|
19
|
+
when /email/ then input_email(field, options)
|
20
|
+
when /phone/ then input_telephone(field, options)
|
21
|
+
when /url/ then input_url(field, options)
|
22
|
+
else input_text(field, options)
|
23
|
+
end
|
24
|
+
end
|
31
25
|
end
|
32
26
|
|
33
|
-
def
|
34
|
-
|
35
|
-
|
36
|
-
end
|
27
|
+
def input_date(field, options = {}); wrap_input(__method__, field, options) { |opts| date_field(field, opts) } end
|
28
|
+
def input_datetime(field, options = {}); wrap_input(__method__, field, options) { |opts| datetime_field(field, opts) } end
|
29
|
+
def input_integer(field, options = {}); wrap_input(__method__, field, options) { |opts| number_field(field, opts) } end
|
30
|
+
def input_number(field, options = {}); wrap_input(__method__, field, options) { |opts| number_field(field, opts) } end
|
31
|
+
def input_password(field, options = {}); wrap_input(__method__, field, options) { |opts| password_field(field, opts) } end
|
32
|
+
def input_email(field, options = {}); wrap_input(__method__, field, options) { |opts| email_field(field, opts) } end
|
33
|
+
def input_url(field, options = {}); wrap_input(__method__, field, options) { |opts| url_field(field, opts) } end
|
34
|
+
def input_text(field, options = {}); wrap_input(__method__, field, options) { |opts| text_field(field, opts) } end
|
35
|
+
def input_file(field, options = {}); wrap_input(__method__, field, options) { |opts| file_field(field, opts) } end
|
36
|
+
def input_telephone(field, options = {}); wrap_input(__method__, field, options) { |opts| telephone_field(field, opts) } end
|
37
|
+
def input_text_area(field, options = {}); wrap_input(__method__, field, options) { |opts| text_area(field, opts) } end
|
38
|
+
def input_check_box(field, options = {}); wrap_input(__method__, field, options) { |opts| check_box(field, opts) } end
|
37
39
|
|
38
|
-
def
|
39
|
-
dry_tag = ActiveDryForm::Input.new(self, __method__, method, options)
|
40
|
-
dry_tag.wrap_tag text_area(method, dry_tag.input_opts)
|
41
|
-
end
|
40
|
+
def input_hidden(field, options = {}); hidden_field(field, options) end
|
42
41
|
|
43
|
-
def
|
44
|
-
|
45
|
-
|
42
|
+
def input_check_box_inline(field, options = {})
|
43
|
+
wrap_input(__method__, field, options, label_last: true) do |opts|
|
44
|
+
check_box(field, opts)
|
45
|
+
end
|
46
46
|
end
|
47
47
|
|
48
|
-
def
|
49
|
-
|
48
|
+
def input_select(field, collection, options = {}, html_options = {})
|
49
|
+
wrap_input(__method__, field, html_options) do |opts|
|
50
|
+
select(field, collection, options, opts)
|
51
|
+
end
|
50
52
|
end
|
51
53
|
|
52
54
|
def show_base_errors
|
53
|
-
return
|
55
|
+
return if @object.base_errors.empty?
|
54
56
|
|
55
|
-
|
56
|
-
|
57
|
+
@template.content_tag(:div, class: ActiveDryForm.config.css_classes.base_error) do
|
58
|
+
@template.content_tag(:ul) do
|
57
59
|
# внутри ошибки может быть html
|
58
|
-
@object.base_errors.map {
|
60
|
+
@object.base_errors.map { @template.content_tag(:li, _1.html_safe) }.join.html_safe
|
59
61
|
end
|
60
62
|
end
|
61
63
|
end
|
62
64
|
|
63
|
-
def show_error(
|
64
|
-
ActiveDryForm::Input.new(self,
|
65
|
+
def show_error(field)
|
66
|
+
ActiveDryForm::Input.new(self, nil, field, {}).error_text
|
65
67
|
end
|
66
68
|
|
67
|
-
def button(value = nil, options = {}, &block)
|
69
|
+
def button(value = nil, options = {}, &block)
|
68
70
|
options[:class] = [options[:class], 'button'].compact
|
69
|
-
super
|
71
|
+
super
|
72
|
+
end
|
73
|
+
|
74
|
+
def fields_for(association_name, fields_options = {}, &block)
|
75
|
+
fields_options[:builder] ||= options[:builder]
|
76
|
+
fields_options[:namespace] = options[:namespace]
|
77
|
+
fields_options[:parent_builder] = self
|
78
|
+
|
79
|
+
association = @object.public_send(association_name)
|
80
|
+
|
81
|
+
if association.is_a?(BaseForm)
|
82
|
+
fields_for_nested_model("#{@object_name}[#{association_name}]", association, fields_options, block)
|
83
|
+
elsif association.respond_to?(:to_ary)
|
84
|
+
field_name_regexp = Regexp.new(Regexp.escape("#{@object_name}[#{association_name}][") << '\d+\]') # хак для замены хеша на массив
|
85
|
+
output = ActiveSupport::SafeBuffer.new
|
86
|
+
Array.wrap(association).each do |child|
|
87
|
+
output << fields_for_nested_model("#{@object_name}[#{association_name}][]", child, fields_options, block)
|
88
|
+
.gsub(field_name_regexp, "#{@object_name}[#{association_name}][]").html_safe
|
89
|
+
end
|
90
|
+
output
|
91
|
+
end
|
70
92
|
end
|
71
|
-
|
72
|
-
|
73
|
-
|
93
|
+
|
94
|
+
ARRAY_NULL = %w[null].freeze
|
95
|
+
private def input_type(field)
|
96
|
+
(Array.wrap(object.info(field)[:type]) - ARRAY_NULL).first
|
97
|
+
end
|
98
|
+
|
99
|
+
private def wrap_input(method_type, field, options, wrapper_options = {})
|
100
|
+
config = ActiveDryForm.config.html_options._settings[method_type] ? ActiveDryForm.config.html_options[method_type] : EMPTY_HASH
|
101
|
+
options = config.merge(options)
|
102
|
+
|
103
|
+
options[:class] = Array.wrap(config[:class]) + Array.wrap(options[:class]) if config[:class]
|
104
|
+
options[:required] = object.info(field)[:required] unless options.key?(:required)
|
105
|
+
|
106
|
+
Input
|
107
|
+
.new(self, method_type, field, options)
|
108
|
+
.wrap_tag(yield(options), **wrapper_options)
|
74
109
|
end
|
75
110
|
|
76
111
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveDryForm
|
4
|
+
|
5
|
+
extend Dry::Configurable
|
6
|
+
include Dry::Core::Constants
|
7
|
+
|
8
|
+
setting :strict_param_keys, default: defined?(::Rails) ? (::Rails.env.development? || ::Rails.env.test?) : true
|
9
|
+
setting :allow_action_controller_params, default: true
|
10
|
+
|
11
|
+
setting :css_classes do
|
12
|
+
setting :error, default: 'form-error'
|
13
|
+
setting :base_error, default: 'form-base-error'
|
14
|
+
setting :hint, default: 'form-hint'
|
15
|
+
setting :input, default: 'form-input'
|
16
|
+
setting :input_required, default: 'form-input-required'
|
17
|
+
setting :input_error, default: 'form-input-error'
|
18
|
+
setting :form, default: ['active-dry-form']
|
19
|
+
end
|
20
|
+
|
21
|
+
setting :html_options do
|
22
|
+
setting :input_check_box, default: EMPTY_HASH
|
23
|
+
setting :input_check_box_inline, default: EMPTY_HASH
|
24
|
+
setting :input_date, default: EMPTY_HASH
|
25
|
+
setting :input_datetime, default: EMPTY_HASH
|
26
|
+
setting :input_email, default: EMPTY_HASH
|
27
|
+
setting :input_file, default: EMPTY_HASH
|
28
|
+
setting :input_integer, default: EMPTY_HASH
|
29
|
+
|
30
|
+
# If without 'any', the fractional part of the number is lost when step is an integer (1 by default)
|
31
|
+
setting :input_number, default: { step: 'any' }
|
32
|
+
setting :input_password, default: EMPTY_HASH
|
33
|
+
setting :input_select, default: EMPTY_HASH
|
34
|
+
setting :input_telephone, default: EMPTY_HASH
|
35
|
+
setting :input_text_area, default: EMPTY_HASH
|
36
|
+
setting :input_text, default: EMPTY_HASH
|
37
|
+
setting :input_url, default: EMPTY_HASH
|
38
|
+
|
39
|
+
setting :form, default: EMPTY_HASH
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
ActiveSupport::Reloader.to_prepare do
|
45
|
+
ActiveDryForm.config.finalize!(freeze_values: true)
|
46
|
+
end
|