active_dry_form 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,56 +3,60 @@
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 ActionView::Helpers::TagHelper
7
+ include ActionView::Context
8
+ include Dry::Core::Constants
27
9
 
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)
10
+ def input(field, options = {})
11
+ case input_type(field)
12
+ when 'date' then input_date(field, options)
13
+ when 'time' then input_datetime(field, options)
14
+ when 'date-time' then raise DateTimeNotAllowedError, 'use :time instead of :date_time (does not apply timezone) in params block'
15
+ when 'integer' then input_integer(field, options)
16
+ when 'number' then input_number(field, options)
17
+ when 'boolean' then input_check_box(field, options)
18
+ else
19
+ case field.to_s
20
+ when /password/ then input_password(field, options)
21
+ when /email/ then input_email(field, options)
22
+ when /phone/ then input_telephone(field, options)
23
+ when /url/ then input_url(field, options)
24
+ else input_text(field, options)
25
+ end
26
+ end
31
27
  end
32
28
 
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
29
+ def input_date(field, options = {}); wrap_input(__method__, field, options) { |opts| date_field(field, opts) } end
30
+ def input_datetime(field, options = {}); wrap_input(__method__, field, options) { |opts| datetime_field(field, opts) } end
31
+ def input_integer(field, options = {}); wrap_input(__method__, field, options) { |opts| number_field(field, opts) } end
32
+ def input_number(field, options = {}); wrap_input(__method__, field, options) { |opts| number_field(field, opts) } end
33
+ def input_password(field, options = {}); wrap_input(__method__, field, options) { |opts| password_field(field, opts) } end
34
+ def input_email(field, options = {}); wrap_input(__method__, field, options) { |opts| email_field(field, opts) } end
35
+ def input_url(field, options = {}); wrap_input(__method__, field, options) { |opts| url_field(field, opts) } end
36
+ def input_text(field, options = {}); wrap_input(__method__, field, options) { |opts| text_field(field, opts) } end
37
+ def input_file(field, options = {}); wrap_input(__method__, field, options) { |opts| file_field(field, opts) } end
38
+ def input_telephone(field, options = {}); wrap_input(__method__, field, options) { |opts| telephone_field(field, opts) } end
39
+ def input_text_area(field, options = {}); wrap_input(__method__, field, options) { |opts| text_area(field, opts) } end
40
+ def input_check_box(field, options = {}); wrap_input(__method__, field, options) { |opts| check_box(field, opts) } end
37
41
 
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
42
+ def input_hidden(field, options = {}); hidden_field(field, options) end
42
43
 
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)
44
+ def input_check_box_inline(field, options = {})
45
+ wrap_input(__method__, field, options, label_last: true) do |opts|
46
+ check_box(field, opts)
47
+ end
46
48
  end
47
49
 
48
- def input_hidden(method, options = {})
49
- hidden_field(method, options)
50
+ def input_select(field, collection, options = {}, html_options = {})
51
+ wrap_input(__method__, field, html_options) do |opts|
52
+ select(field, collection, options, opts)
53
+ end
50
54
  end
51
55
 
52
56
  def show_base_errors
53
- return unless @object.base_errors
57
+ return if @object.base_errors.empty?
54
58
 
55
- tag.div class: 'callout alert' do
59
+ tag.div class: ActiveDryForm.config.css_classes.base_error do
56
60
  tag.ul do
57
61
  # внутри ошибки может быть html
58
62
  @object.base_errors.map { tag.li _1.html_safe }.join.html_safe
@@ -60,17 +64,50 @@ module ActiveDryForm
60
64
  end
61
65
  end
62
66
 
63
- def show_error(method)
64
- ActiveDryForm::Input.new(self, __method__, method, {}).error_text
67
+ def show_error(field)
68
+ ActiveDryForm::Input.new(self, __method__, field, {}).error_text
65
69
  end
66
70
 
67
- def button(value = nil, options = {}, &block) # rubocop:disable Gp/OptArgParameters
71
+ def button(value = nil, options = {}, &block)
68
72
  options[:class] = [options[:class], 'button'].compact
69
- super(value, options, &block)
73
+ super
74
+ end
75
+
76
+ def fields_for(association_name, fields_options = {}, &block)
77
+ fields_options[:builder] ||= options[:builder]
78
+ fields_options[:namespace] = options[:namespace]
79
+ fields_options[:parent_builder] = self
80
+
81
+ association = @object.public_send(association_name)
82
+
83
+ if association.is_a?(BaseForm)
84
+ fields_for_nested_model("#{@object_name}[#{association_name}]", association, fields_options, block)
85
+ elsif association.respond_to?(:to_ary)
86
+ field_name_regexp = Regexp.new(Regexp.escape("#{@object_name}[#{association_name}][") << '\d+\]') # хак для замены хеша на массив
87
+ output = ActiveSupport::SafeBuffer.new
88
+ Array.wrap(association).each do |child|
89
+ output << fields_for_nested_model("#{@object_name}[#{association_name}][]", child, fields_options, block)
90
+ .gsub(field_name_regexp, "#{@object_name}[#{association_name}][]").html_safe
91
+ end
92
+ output
93
+ end
70
94
  end
71
-
72
- def fields_for(record_name, &block)
73
- super(@object.send(record_name), &block)
95
+
96
+ ARRAY_NULL = %w[null].freeze
97
+ private def input_type(field)
98
+ (Array.wrap(object.info(field)[:type]) - ARRAY_NULL).first
99
+ end
100
+
101
+ private def wrap_input(method_type, field, options, wrapper_options = {})
102
+ config = ActiveDryForm.config.html_options._settings[method_type] ? ActiveDryForm.config.html_options[method_type] : EMPTY_HASH
103
+ options = config.merge(options)
104
+
105
+ options[:class] = Array.wrap(config[:class]) + Array.wrap(options[:class]) if config[:class]
106
+ options[:required] = object.info(field)[:required] unless options.key?(:required)
107
+
108
+ Input
109
+ .new(self, method_type, field, options)
110
+ .wrap_tag(yield(options), **wrapper_options)
74
111
  end
75
112
 
76
113
  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