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.
@@ -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
- def input(method, options = {})
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 input_select(method, collection, options = {}, html_options = {}) # rubocop:disable Gp/OptArgParameters
29
- dry_tag = ActiveDryForm::Input.new(self, __method__, method, options)
30
- dry_tag.wrap_tag select(method, collection, dry_tag.input_opts, html_options)
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 input_checkbox_inline(method, options = {})
34
- dry_tag = ActiveDryForm::Input.new(self, __method__, method, options)
35
- dry_tag.wrap_tag check_box(method, dry_tag.input_opts), label_last: true
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 input_text(method, options = {})
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 input_file(method, options = {})
44
- dry_tag = ActiveDryForm::Input.new(self, __method__, method, options)
45
- dry_tag.wrap_tag file_field(method, dry_tag.input_opts)
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 input_hidden(method, options = {})
49
- hidden_field(method, options)
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 unless @object.base_errors
55
+ return if @object.base_errors.empty?
54
56
 
55
- tag.div class: 'callout alert' do
56
- tag.ul do
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 { tag.li _1.html_safe }.join.html_safe
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(method)
64
- ActiveDryForm::Input.new(self, __method__, method, {}).error_text
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) # rubocop:disable Gp/OptArgParameters
69
+ def button(value = nil, options = {}, &block)
68
70
  options[:class] = [options[:class], 'button'].compact
69
- super(value, options, &block)
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
- def fields_for(record_name, &block)
73
- super(@object.send(record_name), &block)
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